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:
parent
25d17b481d
commit
3f47d2ff26
6 changed files with 419 additions and 261 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue