feat: introduce CategoryFilterBar and ProductGrid components for enhanced product filtering and display

- Added CategoryFilterBar.vue to manage category filtering with AND/OR toggle options and clear all functionality.
- Implemented ProductGrid.vue to display products with loading and empty states, improving user experience.
- Refactored MarketPage.vue to utilize the new components, streamlining the layout and enhancing responsiveness.
- Updated StallView.vue to incorporate ProductGrid for consistent product display across views.

These changes enhance the overall usability and visual appeal of the market components, providing users with a more intuitive filtering and browsing experience.
This commit is contained in:
padreug 2025-09-26 23:39:08 +02:00
parent 25d17b481d
commit 3f47d2ff26
6 changed files with 419 additions and 261 deletions

View file

@ -59,146 +59,28 @@
<!-- Enhanced Category Filters -->
<section
v-if="allCategories.length > 0"
class="mb-4 sm:mb-6"
aria-labelledby="category-filters-heading"
>
<div class="flex items-center justify-between mb-2 sm:mb-3">
<div class="flex items-center gap-2 sm:gap-4">
<h3 id="category-filters-heading" class="text-sm sm:text-lg font-semibold text-gray-700">
Browse by Category
</h3>
<CategoryFilterBar
:categories="allCategories"
:selected-count="selectedCategoriesCount"
:filter-mode="filterMode"
:product-count="productsToDisplay.length"
@toggle-category="toggleCategory"
@clear-all="clearAllCategoryFilters"
@set-filter-mode="setFilterMode"
/>
<!-- AND/OR Filter Mode Toggle -->
<div
v-if="selectedCategoriesCount > 1"
class="flex items-center gap-1 sm:gap-2"
role="group"
aria-label="Filter mode selection"
>
<span class="text-xs text-muted-foreground hidden sm:inline">Match:</span>
<Button
@click="setFilterMode('any')"
:variant="filterMode === 'any' ? 'default' : 'outline'"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-xs"
:aria-pressed="filterMode === 'any'"
aria-label="Show products with any selected category"
>
Any
</Button>
<Button
@click="setFilterMode('all')"
:variant="filterMode === 'all' ? 'default' : 'outline'"
size="sm"
class="h-5 sm:h-6 px-1.5 sm:px-2 text-xs"
:aria-pressed="filterMode === 'all'"
aria-label="Show products with all selected categories"
>
All
</Button>
</div>
</div>
<Button
v-if="selectedCategoriesCount > 0"
@click="clearAllCategoryFilters"
variant="ghost"
size="sm"
class="text-xs sm:text-sm px-2 sm:px-3 py-1 sm:py-2"
:aria-label="`Clear all ${selectedCategoriesCount} selected category filters`"
>
<span class="hidden sm:inline">Clear All </span><span class="sm:hidden">Clear </span>({{ selectedCategoriesCount }})
<X class="w-3 h-3 sm:w-4 sm:h-4 ml-1" aria-hidden="true" />
</Button>
</div>
<div
class="flex flex-wrap gap-1.5 sm:gap-3"
role="group"
aria-label="Filter products by category"
>
<div
v-for="category in allCategories"
:key="category.category"
:id="`category-filter-${category.category}`"
role="button"
:aria-pressed="category.selected"
:aria-label="`${category.selected ? 'Remove' : 'Add'} ${category.category} filter. ${category.count} products available.`"
:tabindex="0"
@click="toggleCategory(category.category)"
@keydown.enter="toggleCategory(category.category)"
@keydown.space.prevent="toggleCategory(category.category)"
class="group relative cursor-pointer transition-all duration-200 hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1"
>
<Badge
:variant="category.selected ? 'default' : 'outline'"
class="px-2 py-1 sm:px-4 sm:py-2 text-xs sm:text-sm font-medium transition-all duration-200"
:class="{
'bg-primary text-primary-foreground shadow-md': category.selected,
'hover:bg-primary/10 hover:border-primary': !category.selected,
'ring-2 ring-primary ring-offset-1': category.selected
}"
:aria-hidden="true"
>
<div class="flex items-center gap-1 sm:gap-2">
<span>{{ category.category }}</span>
<div
class="px-1 py-0.5 sm:px-2 rounded-full text-xs font-bold transition-colors"
:class="category.selected
? 'bg-primary-foreground/20 text-primary-foreground'
: 'bg-secondary text-secondary-foreground'"
>
{{ category.count }}
</div>
</div>
</Badge>
<!-- Screen reader only text for selection state -->
<span class="sr-only">
{{ category.selected ? `${category.category} filter is active` : `${category.category} filter is inactive` }}
</span>
<!-- Selection indicator -->
<div
v-if="category.selected"
class="absolute -top-0.5 -right-0.5 sm:-top-1 sm:-right-1 w-2.5 h-2.5 sm:w-3 sm:h-3 bg-green-500 rounded-full border-2 border-white shadow-sm"
>
<Check class="w-1.5 h-1.5 sm:w-2 sm:h-2 text-white absolute top-0 left-0 sm:top-0.5 sm:left-0.5" />
</div>
</div>
</div>
<!-- Product Count (when filters active) -->
<div
v-if="selectedCategoriesCount > 0"
class="mt-2 text-center"
aria-live="polite"
>
<span class="text-xs sm:text-sm text-muted-foreground">
{{ productsToDisplay.length }} products found
</span>
</div>
</section>
<!-- No Products State -->
<div v-if="isMarketReady && productsToDisplay.length === 0 && !(marketStore.isLoading ?? false)" class="text-center py-12">
<h3 class="text-xl font-semibold text-gray-600 mb-2">No products found</h3>
<p class="text-gray-500">Try adjusting your search or filters</p>
</div>
<!-- Product Grid -->
<div v-if="isMarketReady && productsToDisplay.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="product in productsToDisplay"
:key="product.id"
:product="product as Product"
@add-to-cart="addToCart"
@view-details="viewProduct"
@view-stall="viewStall"
/>
</div>
<!-- Product Grid with Loading and Empty States -->
<ProductGrid
v-if="isMarketReady"
:products="productsToDisplay as Product[]"
:is-loading="marketStore.isLoading ?? false"
loading-message="Loading products..."
empty-title="No products found"
empty-message="Try adjusting your search or filters"
@add-to-cart="addToCart"
@view-details="viewProduct"
@view-stall="viewStall"
/>
<!-- Cart Summary -->
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
@ -208,6 +90,15 @@
</Button>
</div>
</div>
<!-- Product Detail Dialog -->
<ProductDetailDialog
v-if="selectedProduct"
:product="selectedProduct"
:isOpen="showProductDetail"
@close="closeProductDetail"
@add-to-cart="handleAddToCart"
/>
</div>
</template>
@ -220,11 +111,12 @@ import { useMarketPreloader } from '../composables/useMarketPreloader'
import { useCategoryFilter } from '../composables/useCategoryFilter'
import { config } from '@/lib/config'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { ShoppingCart, X, Check } from 'lucide-vue-next'
import { ShoppingCart } from 'lucide-vue-next'
import MarketFuzzySearch from '../components/MarketFuzzySearch.vue'
import ProductCard from '../components/ProductCard.vue'
import ProductGrid from '../components/ProductGrid.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import ProductDetailDialog from '../components/ProductDetailDialog.vue'
import type { Product } from '../types/market'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
@ -257,6 +149,10 @@ let unsubscribe: (() => void) | null = null
// Fuzzy search state
const searchResults = ref<Product[]>([])
// Product detail dialog state
const showProductDetail = ref(false)
const selectedProduct = ref<Product | null>(null)
// Fuzzy search configuration for products and stalls
const searchOptions: FuzzySearchOptions<Product> = {
fuseOptions: {
@ -360,8 +256,19 @@ const addToCart = (product: any) => {
marketStore.addToCart(product)
}
const viewProduct = (_product: any) => {
// TODO: Navigate to product detail page
const viewProduct = (product: Product) => {
selectedProduct.value = product
showProductDetail.value = true
}
const closeProductDetail = () => {
showProductDetail.value = false
selectedProduct.value = null
}
const handleAddToCart = (product: Product, quantity: number) => {
marketStore.addToCart({ ...product, quantity })
closeProductDetail()
}
const viewStall = (stallId: string) => {