
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
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.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/product-grid-page.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/product-grid-page.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/product-grid-page.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/product-grid-page.jsonnpx shadcn-vue@latest add @uipkge/product-grid-pageInstalls to:app/components/blocks/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
Type aliases exported from this item's source. Use these to shape the data you pass in.
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
}CategoryOptioninterface CategoryOption {
id: string
label: string
}BrandOptioninterface BrandOption {
id: string
label: string
}ColorOptioninterface ColorOption {
id: string
label: string
class: string
}<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { computed, ref } from 'vue'
import {
Check,
ChevronLeft,
ChevronRight,
Heart,
RotateCcw,
ShoppingBag,
SlidersHorizontal,
Star,
X,
} from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet'
import ProductFilterSidebar from './ProductFilterSidebar.vue'
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
}
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
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,
},
]
const selectedCategories = ref<string[]>([])
const priceRange = ref<[number, number]>([50, 300])
const selectedColors = ref<string[]>([])
const selectedBrands = ref<string[]>([])
const minRating = ref<number>(0)
const sortBy = ref<string>('featured')
const wishlistIds = ref<Set<string>>(new Set(['prod-1', 'prod-5']))
const addedToCartIds = ref<Set<string>>(new Set())
const activeProductColor = ref<Record<string, string>>({})
const page = ref(1)
const totalPages = 3
const isMobileFilterOpen = ref(false)
function toggleCategory(id: string, checked: boolean) {
if (checked) {
selectedCategories.value = [...selectedCategories.value, id]
} else {
selectedCategories.value = selectedCategories.value.filter((item) => item !== id)
}
page.value = 1
}
function toggleBrand(id: string, checked: boolean) {
if (checked) {
selectedBrands.value = [...selectedBrands.value, id]
} else {
selectedBrands.value = selectedBrands.value.filter((item) => item !== id)
}
page.value = 1
}
function toggleColor(id: string) {
if (selectedColors.value.includes(id)) {
selectedColors.value = selectedColors.value.filter((item) => item !== id)
} else {
selectedColors.value = [...selectedColors.value, id]
}
page.value = 1
}
function setRating(val: number) {
if (minRating.value === val) {
minRating.value = 0
} else {
minRating.value = val
}
page.value = 1
}
function toggleWishlist(id: string) {
const next = new Set(wishlistIds.value)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
wishlistIds.value = next
}
function isWishlisted(id: string) {
return wishlistIds.value.has(id)
}
function addToCart(id: string) {
const next = new Set(addedToCartIds.value)
next.add(id)
addedToCartIds.value = next
setTimeout(() => {
const updated = new Set(addedToCartIds.value)
updated.delete(id)
addedToCartIds.value = updated
}, 1600)
}
function isAddedToCart(id: string) {
return addedToCartIds.value.has(id)
}
function selectProductColor(productId: string, colorCode: string) {
activeProductColor.value = {
...activeProductColor.value,
[productId]: colorCode,
}
}
function resetFilters() {
selectedCategories.value = []
priceRange.value = [50, 300]
selectedColors.value = []
selectedBrands.value = []
minRating.value = 0
page.value = 1
}
const hasActiveFilters = computed(() => {
return (
selectedCategories.value.length > 0 ||
selectedBrands.value.length > 0 ||
selectedColors.value.length > 0 ||
minRating.value > 0 ||
priceRange.value[0] > 50 ||
priceRange.value[1] < 300
)
})
const activeFilterCount = computed(() => {
let count = 0
if (selectedCategories.value.length > 0) count += selectedCategories.value.length
if (selectedBrands.value.length > 0) count += selectedBrands.value.length
if (selectedColors.value.length > 0) count += selectedColors.value.length
if (minRating.value > 0) count += 1
if (priceRange.value[0] > 50 || priceRange.value[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 = computed(() => {
return PRODUCTS.filter((product) => {
if (selectedCategories.value.length > 0 && !selectedCategories.value.includes(product.categoryId)) {
return false
}
if (product.price < priceRange.value[0] || product.price > priceRange.value[1]) {
return false
}
if (selectedColors.value.length > 0) {
const hasMatchingColor = product.colors.some((c) => selectedColors.value.includes(c.colorCode))
if (!hasMatchingColor) return false
}
if (selectedBrands.value.length > 0 && !selectedBrands.value.includes(product.brand)) {
return false
}
if (minRating.value > 0 && product.rating < minRating.value) {
return false
}
return true
}).sort((a, b) => {
if (sortBy.value === 'price-asc') return a.price - b.price
if (sortBy.value === 'price-desc') return b.price - a.price
if (sortBy.value === 'rating-desc') return b.rating - a.rating
if (sortBy.value === 'newest') return b.createdAt - a.createdAt
return a.featuredRank - b.featuredRank
})
})
</script>
<template>
<div data-slot="product-grid-page" :class="cn('w-full space-y-8', props.class)">
<!-- 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 v-model:open="isMobileFilterOpen">
<SheetTrigger as-child>
<Button variant="outline" size="sm" class="gap-2 md:hidden">
<SlidersHorizontal class="size-4" />
<span>Filters</span>
<Badge v-if="activeFilterCount > 0" variant="secondary" class="ml-1 px-1.5 py-0 text-xs">
{{ activeFilterCount }}
</Badge>
</Button>
</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>
<Button
v-if="hasActiveFilters"
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>
<SheetDescription class="text-xs"> Narrow down footwear by category, price, and specs. </SheetDescription>
</SheetHeader>
<div class="mt-6">
<ProductFilterSidebar
is-mobile
:selected-categories="selectedCategories"
:price-range="priceRange"
:selected-colors="selectedColors"
:selected-brands="selectedBrands"
:min-rating="minRating"
:get-category-count="getCategoryCount"
:get-brand-count="getBrandCount"
:on-toggle-category="toggleCategory"
:on-price-range-change="(v) => (priceRange = v)"
:on-toggle-color="toggleColor"
:on-toggle-brand="toggleBrand"
:on-select-rating="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 v-model="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>
<Button
v-if="hasActiveFilters"
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>
<Separator />
<ProductFilterSidebar
:selected-categories="selectedCategories"
:price-range="priceRange"
:selected-colors="selectedColors"
:selected-brands="selectedBrands"
:min-rating="minRating"
:get-category-count="getCategoryCount"
:get-brand-count="getBrandCount"
:on-toggle-category="toggleCategory"
:on-price-range-change="(v) => (priceRange = v)"
:on-toggle-color="toggleColor"
:on-toggle-brand="toggleBrand"
:on-select-rating="setRating"
/>
</aside>
<!-- Right Product Grid Area -->
<section class="min-w-0 space-y-6">
<!-- Active Filter Chips -->
<div v-if="hasActiveFilters" class="flex flex-wrap items-center gap-2">
<Badge v-for="catId in selectedCategories" :key="catId" 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"
@click="toggleCategory(catId, false)"
>
<X class="size-3" />
</button>
</Badge>
<Badge v-for="brand in selectedBrands" :key="brand" 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)"
>
<X class="size-3" />
</button>
</Badge>
<Badge v-for="colorId in selectedColors" :key="colorId" 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"
@click="toggleColor(colorId)"
>
<X class="size-3" />
</button>
</Badge>
<Badge v-if="minRating > 0" 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 = 0"
>
<X class="size-3" />
</button>
</Badge>
<Badge v-if="priceRange[0] > 50 || priceRange[1] < 300" 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 = [50, 300]"
>
<X class="size-3" />
</button>
</Badge>
<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 -->
<div
v-if="filteredProducts.length === 0"
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" @click="resetFilters">
<RotateCcw class="size-3.5" />
<span>Reset filters</span>
</Button>
</div>
<!-- 3-Column Product Grid -->
<div v-else class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div
v-for="product in filteredProducts"
:key="product.id"
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 -->
<div v-if="product.badge" 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>
<!-- Top Right Wishlist Heart -->
<button
type="button"
aria-label="Toggle wishlist"
class="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"
:class="{ 'text-destructive hover:text-destructive': isWishlisted(product.id) }"
@click.stop="toggleWishlist(product.id)"
>
<Heart
class="size-4 transition-transform active:scale-90"
:class="{ 'fill-current': isWishlisted(product.id) }"
/>
</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"
:variant="isAddedToCart(product.id) ? 'default' : 'default'"
@click.stop="addToCart(product.id)"
>
<Check v-if="isAddedToCart(product.id)" class="size-3.5" />
<ShoppingBag v-else class="size-3.5" />
<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">
<button
v-for="color in product.colors"
:key="color.id"
type="button"
:title="color.name"
class="size-6 rounded-full transition-opacity hover:opacity-90 focus-visible:outline-none"
:class="[
color.class,
activeProductColor[product.id] === color.colorCode
? 'ring-1.5 ring-primary ring-offset-background ring-offset-1'
: '',
]"
@click.stop="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>
<!-- 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>
<span v-if="product.originalPrice" class="text-muted-foreground text-xs line-through">
${{ product.originalPrice.toFixed(2) }}
</span>
<span v-if="product.originalPrice" class="text-success text-xs font-medium">
Save ${{ (product.originalPrice - product.price).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
<!-- Pagination Footer -->
<nav
v-if="filteredProducts.length > 0"
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" @click="page--">
<ChevronLeft class="size-4" />
<span>Previous</span>
</Button>
<div class="flex items-center gap-1">
<Button
v-for="n in totalPages"
:key="n"
:variant="n === page ? 'outline' : 'ghost'"
size="sm"
class="size-8 p-0 text-xs tabular-nums"
:aria-current="n === page ? 'page' : undefined"
@click="page = n"
>
{{ n }}
</Button>
</div>
<Button variant="outline" size="sm" class="gap-1 text-xs" :disabled="page === totalPages" @click="page++">
<span>Next</span>
<ChevronRight class="size-4" />
</Button>
</nav>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { Check, Star } from 'lucide-vue-next'
import { Checkbox } from '@/components/ui/checkbox'
import { Separator } from '@/components/ui/separator'
import { Slider } from '@/components/ui/slider'
import { BRANDS, CATEGORIES, COLORS, RATING_OPTIONS } from './product-filter-data'
const props = withDefaults(
defineProps<{
isMobile?: boolean
selectedCategories: string[]
priceRange: [number, number]
selectedColors: string[]
selectedBrands: string[]
minRating: number
getCategoryCount: (id: string) => number
getBrandCount: (brand: string) => number
onToggleCategory: (id: string, checked: boolean) => void
onPriceRangeChange: (value: [number, number]) => void
onToggleColor: (id: string) => void
onToggleBrand: (id: string, checked: boolean) => void
onSelectRating: (value: number) => void
}>(),
{ isMobile: false },
)
const idPrefix = props.isMobile ? 'mobile-' : ''
</script>
<template>
<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">
<div v-for="cat in CATEGORIES" :key="cat.id" class="flex items-center justify-between">
<label
:for="`${idPrefix}cat-${cat.id}`"
class="text-foreground flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
:id="`${idPrefix}cat-${cat.id}`"
:model-value="selectedCategories.includes(cat.id)"
@update:model-value="onToggleCategory(cat.id, $event === true)"
/>
<span>{{ cat.label }}</span>
</label>
<span class="text-muted-foreground text-xs tabular-nums">{{ getCategoryCount(cat.id) }}</span>
</div>
</div>
</div>
<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>
<Slider
:model-value="priceRange"
:min="50"
:max="300"
:step="5"
:range="true"
class="py-2"
@update:model-value="onPriceRangeChange($event as [number, number])"
/>
<div class="text-muted-foreground flex items-center justify-between text-xs">
<span>$50</span>
<span>$300</span>
</div>
</div>
<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">
<button
v-for="color in COLORS"
:key="color.id"
type="button"
:title="color.label"
class="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"
:class="[
isMobile ? 'size-8' : 'size-7',
color.class,
selectedColors.includes(color.id)
? 'ring-primary ring-offset-background ring-2 ring-offset-2'
: 'border-border',
]"
@click="onToggleColor(color.id)"
>
<Check
v-if="selectedColors.includes(color.id)"
:class="[isMobile ? 'size-4' : 'size-3.5', color.id === 'white' ? 'text-zinc-950' : 'text-white']"
/>
<span class="sr-only">{{ color.label }}</span>
</button>
</div>
</div>
<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">
<div v-for="b in BRANDS" :key="b.id" class="flex items-center justify-between">
<label
:for="`${idPrefix}brand-${b.id}`"
class="text-foreground flex cursor-pointer items-center gap-2 text-sm"
>
<Checkbox
:id="`${idPrefix}brand-${b.id}`"
:model-value="selectedBrands.includes(b.id)"
@update:model-value="onToggleBrand(b.id, $event === true)"
/>
<span>{{ b.label }}</span>
</label>
<span class="text-muted-foreground text-xs tabular-nums">{{ getBrandCount(b.id) }}</span>
</div>
</div>
</div>
<Separator />
<!-- Rating -->
<div class="space-y-3">
<h3 class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
{{ isMobile ? 'Rating' : 'Customer Rating' }}
</h3>
<div :class="isMobile ? 'space-y-2' : 'space-y-1.5'">
<button
v-for="opt in RATING_OPTIONS"
:key="opt.value"
type="button"
class="hover:bg-muted/60 flex w-full items-center justify-between rounded-md px-2 py-1.5 text-left text-xs transition-colors"
:class="minRating === opt.value ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'"
@click="onSelectRating(opt.value)"
>
<div class="flex items-center gap-1.5">
<Star class="fill-warning text-warning size-3.5" />
<span>{{ opt.label }}</span>
</div>
<Check v-if="minRating === opt.value" class="text-primary size-3.5" />
</button>
</div>
</div>
</div>
</template>
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' },
]
Raw manifest:https://uipkge.dev/r/vue/product-grid-page.json