- Introduced a new `useCategoryFilter` composable to manage category filtering functionality, providing reactive state management and optimized performance for product filtering based on selected categories. - Updated `MarketPage` to integrate the new composable, enhancing the UI with improved accessibility features, including ARIA roles and labels for category filters and active filters summary. - Refactored category filtering logic to streamline product display based on selected categories, improving user experience in navigating and filtering products. These changes enhance the overall functionality and usability of category filtering within the market module.
361 lines
13 KiB
Vue
361 lines
13 KiB
Vue
<template>
|
|
<div class="container mx-auto px-4 py-8">
|
|
<!-- Loading State -->
|
|
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
|
|
<div class="flex flex-col items-center space-y-4">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
|
<p class="text-gray-600">
|
|
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0" class="flex justify-center items-center min-h-64">
|
|
<div class="text-center">
|
|
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
|
|
<p class="text-gray-600 mb-4">{{ marketStore.error || marketPreloader.preloadError }}</p>
|
|
<Button @click="retryLoadMarket" variant="outline">
|
|
Try Again
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Market Content -->
|
|
<div v-else>
|
|
<!-- Market Header -->
|
|
<div class="flex items-center justify-between mb-8">
|
|
<div class="flex items-center space-x-4">
|
|
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
|
|
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
|
|
<AvatarFallback>M</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<h1 class="text-3xl font-bold">
|
|
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
|
|
</h1>
|
|
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
|
|
{{ marketStore.activeMarket.opts.description }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Enhanced Fuzzy Search Bar -->
|
|
<div class="flex-1 max-w-md ml-8">
|
|
<MarketFuzzySearch
|
|
:data="marketStore.products"
|
|
:options="searchOptions"
|
|
@results="handleSearchResults"
|
|
@filter-category="handleCategoryFilter"
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<!-- Enhanced Category Filters -->
|
|
<section
|
|
v-if="allCategories.length > 0"
|
|
class="mb-6"
|
|
aria-labelledby="category-filters-heading"
|
|
>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 id="category-filters-heading" class="text-lg font-semibold text-gray-700">
|
|
Browse by Category
|
|
</h3>
|
|
<Button
|
|
v-if="selectedCategoriesCount > 0"
|
|
@click="clearAllCategoryFilters"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="text-sm"
|
|
:aria-label="`Clear all ${selectedCategoriesCount} selected category filters`"
|
|
>
|
|
Clear All ({{ selectedCategoriesCount }})
|
|
<X class="w-4 h-4 ml-1" aria-hidden="true" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-wrap 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-4 py-2 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-2">
|
|
<span>{{ category.category }}</span>
|
|
<div
|
|
class="px-2 py-0.5 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-1 -right-1 w-3 h-3 bg-green-500 rounded-full border-2 border-white shadow-sm"
|
|
>
|
|
<Check class="w-2 h-2 text-white absolute top-0.5 left-0.5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Active Filters Summary -->
|
|
<div
|
|
v-if="selectedCategoriesCount > 0"
|
|
class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200"
|
|
role="region"
|
|
aria-label="Active category filters"
|
|
aria-live="polite"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-2">
|
|
<Filter class="w-4 h-4 text-blue-600" aria-hidden="true" />
|
|
<span class="text-sm font-medium text-blue-800">
|
|
Active Filters:
|
|
</span>
|
|
<div class="flex gap-1" role="list" aria-label="Selected category filters">
|
|
<Badge
|
|
v-for="category in selectedCategories"
|
|
:key="category"
|
|
variant="secondary"
|
|
class="text-xs"
|
|
role="listitem"
|
|
>
|
|
{{ category }}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
<div class="text-sm text-blue-600" aria-live="polite">
|
|
{{ productsToDisplay.length }} products found
|
|
</div>
|
|
</div>
|
|
</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"
|
|
@add-to-cart="addToCart"
|
|
@view-details="viewProduct"
|
|
@view-stall="viewStall"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Cart Summary -->
|
|
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
|
<Button @click="viewCart" class="shadow-lg">
|
|
<ShoppingCart class="w-5 h-5 mr-2" />
|
|
Cart ({{ marketStore.totalCartItems }})
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onMounted, onUnmounted, computed, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useMarketStore } from '@/modules/market/stores/market'
|
|
import { useMarket } from '../composables/useMarket'
|
|
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, Filter } from 'lucide-vue-next'
|
|
import MarketFuzzySearch from '../components/MarketFuzzySearch.vue'
|
|
import ProductCard from '../components/ProductCard.vue'
|
|
import type { Product } from '../types/market'
|
|
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
|
|
|
const router = useRouter()
|
|
const marketStore = useMarketStore()
|
|
const market = useMarket()
|
|
const marketPreloader = useMarketPreloader()
|
|
|
|
// Category filtering with optimized composable
|
|
const {
|
|
allCategories,
|
|
filteredProducts: categoryFilteredProducts,
|
|
selectedCount: selectedCategoriesCount,
|
|
selectedCategoryNames: selectedCategories,
|
|
hasActiveFilters,
|
|
toggleCategory,
|
|
clearAllCategories: clearAllCategoryFilters,
|
|
categoryStats
|
|
} = useCategoryFilter(computed(() => marketStore.products))
|
|
|
|
let unsubscribe: (() => void) | null = null
|
|
|
|
// Fuzzy search state
|
|
const searchResults = ref<Product[]>([])
|
|
|
|
// Fuzzy search configuration for products and stalls
|
|
const searchOptions: FuzzySearchOptions<Product> = {
|
|
fuseOptions: {
|
|
keys: [
|
|
{ name: 'name', weight: 0.7 }, // Product name has highest weight
|
|
{ name: 'stallName', weight: 0.5 }, // Stall name is important for discovery
|
|
{ name: 'description', weight: 0.3 }, // Description provides context
|
|
{ name: 'categories', weight: 0.4 } // Categories help with discovery
|
|
],
|
|
threshold: 0.6, // More tolerant of typos (0.0 = perfect match, 1.0 = match anything)
|
|
ignoreLocation: true, // Don't care about where in the string the match is
|
|
findAllMatches: true, // Find all matches, not just the first
|
|
minMatchCharLength: 2, // Minimum length of a matched character sequence
|
|
shouldSort: true // Sort results by score
|
|
},
|
|
resultLimit: 50, // Limit results for performance
|
|
minSearchLength: 2, // Start searching after 2 characters
|
|
matchAllWhenSearchEmpty: true
|
|
}
|
|
|
|
// Check if we need to load market data
|
|
const needsToLoadMarket = computed(() => {
|
|
return !marketPreloader.isPreloaded.value &&
|
|
!marketPreloader.isPreloading.value &&
|
|
marketStore.products.length === 0
|
|
})
|
|
|
|
// Check if market data is ready (either preloaded or loaded)
|
|
const isMarketReady = computed(() => {
|
|
const isLoading = marketStore.isLoading ?? false
|
|
const ready = marketPreloader.isPreloaded.value ||
|
|
(marketStore.products.length > 0 && !isLoading)
|
|
|
|
return ready
|
|
})
|
|
|
|
// Products to display (combines search results with category filtering)
|
|
const productsToDisplay = computed(() => {
|
|
// Start with either search results or all products
|
|
const baseProducts = searchResults.value.length > 0
|
|
? searchResults.value
|
|
: marketStore.products
|
|
|
|
// Apply category filtering using our composable
|
|
if (!hasActiveFilters.value) {
|
|
return baseProducts
|
|
}
|
|
|
|
// Filter base products by selected categories
|
|
return baseProducts.filter(product => {
|
|
if (!product.categories?.length) return false
|
|
return product.categories.some(cat =>
|
|
selectedCategories.value.includes(cat.toLowerCase())
|
|
)
|
|
})
|
|
})
|
|
|
|
const loadMarket = async () => {
|
|
try {
|
|
const naddr = config.market.defaultNaddr
|
|
if (!naddr) {
|
|
throw new Error('No market naddr configured')
|
|
}
|
|
|
|
await market.connectToMarket()
|
|
await market.loadMarket(naddr)
|
|
|
|
// Subscribe to real-time updates
|
|
unsubscribe = market.subscribeToMarketUpdates()
|
|
|
|
} catch (error) {
|
|
marketStore.setError(error instanceof Error ? error.message : 'Failed to load market')
|
|
}
|
|
}
|
|
|
|
const retryLoadMarket = () => {
|
|
marketStore.setError(null)
|
|
marketPreloader.resetPreload()
|
|
loadMarket()
|
|
}
|
|
|
|
const addToCart = (product: any) => {
|
|
marketStore.addToCart(product)
|
|
}
|
|
|
|
const viewProduct = (_product: any) => {
|
|
// TODO: Navigate to product detail page
|
|
}
|
|
|
|
const viewStall = (stallId: string) => {
|
|
// Navigate to the stall view page
|
|
router.push(`/market/stall/${stallId}`)
|
|
}
|
|
|
|
const viewCart = () => {
|
|
router.push('/cart')
|
|
}
|
|
|
|
// Handle fuzzy search results
|
|
const handleSearchResults = (results: Product[]) => {
|
|
searchResults.value = results
|
|
}
|
|
|
|
// Handle category filtering from fuzzy search
|
|
const handleCategoryFilter = (category: string) => {
|
|
toggleCategory(category)
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Only load market if it hasn't been preloaded
|
|
if (needsToLoadMarket.value) {
|
|
loadMarket()
|
|
} else if (marketPreloader.isPreloaded.value) {
|
|
// Subscribe to real-time updates if market was preloaded
|
|
unsubscribe = market.subscribeToMarketUpdates()
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (unsubscribe) {
|
|
unsubscribe()
|
|
}
|
|
market.disconnectFromMarket()
|
|
})
|
|
</script>
|