web-app/src/modules/market/views/MarketPage.vue
padreug 3f47d2ff26 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.
2025-09-26 23:39:08 +02:00

309 lines
10 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 - Optimized for Mobile -->
<div class="mb-4 sm:mb-6 lg:mb-8">
<!-- Market Info and Search - Responsive Layout -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 sm:gap-4 lg:gap-6">
<!-- Market Info - Compact on Mobile -->
<div class="flex items-center space-x-3 sm:space-x-4">
<Avatar v-if="marketStore.activeMarket?.opts?.logo" class="h-10 w-10 sm:h-12 sm:w-12">
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
<AvatarFallback>M</AvatarFallback>
</Avatar>
<div>
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold">
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
</h1>
<p v-if="marketStore.activeMarket?.opts?.description" class="text-muted-foreground text-xs sm:text-sm lg:text-base line-clamp-1 sm:line-clamp-2">
{{ marketStore.activeMarket.opts.description }}
</p>
</div>
</div>
<!-- Enhanced Fuzzy Search Bar - Full Width on Mobile -->
<div class="w-full lg:flex-1 lg:max-w-md">
<MarketFuzzySearch
:data="marketStore.products as Product[]"
:options="searchOptions"
@results="handleSearchResults"
@filter-category="handleCategoryFilter"
class="w-full"
/>
</div>
</div>
</div>
<!-- Enhanced Category Filters -->
<CategoryFilterBar
:categories="allCategories"
:selected-count="selectedCategoriesCount"
:filter-mode="filterMode"
:product-count="productsToDisplay.length"
@toggle-category="toggleCategory"
@clear-all="clearAllCategoryFilters"
@set-filter-mode="setFilterMode"
/>
<!-- 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">
<Button @click="viewCart" class="shadow-lg">
<ShoppingCart class="w-5 h-5 mr-2" />
Cart ({{ marketStore.totalCartItems }})
</Button>
</div>
</div>
<!-- Product Detail Dialog -->
<ProductDetailDialog
v-if="selectedProduct"
:product="selectedProduct"
:isOpen="showProductDetail"
@close="closeProductDetail"
@add-to-cart="handleAddToCart"
/>
</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 { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { ShoppingCart } from 'lucide-vue-next'
import MarketFuzzySearch from '../components/MarketFuzzySearch.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'
const router = useRouter()
const marketStore = useMarketStore()
const market = useMarket()
const marketPreloader = useMarketPreloader()
// Dynamic category filtering: use search results when available, otherwise all products
const productsForCategoryFilter = computed(() => {
return searchResults.value.length > 0
? searchResults.value
: (marketStore.products as Product[])
})
// Category filtering with optimized composable
const {
allCategories,
selectedCount: selectedCategoriesCount,
selectedCategoryNames: selectedCategories,
hasActiveFilters,
filterMode,
toggleCategory,
clearAllCategories: clearAllCategoryFilters,
setFilterMode
} = useCategoryFilter(productsForCategoryFilter)
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: {
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.4, // 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's proper AND/OR logic
if (!hasActiveFilters.value) {
return baseProducts
}
// Use the composable's filtering logic which supports AND/OR modes
return baseProducts.filter(product => {
if (!product.categories?.length) return false
// Normalize product categories
const productCategories = product.categories
.filter(cat => cat && cat.trim())
.map(cat => cat.toLowerCase())
if (productCategories.length === 0) return false
// Count matches between product categories and selected categories
const matchingCategories = productCategories.filter(cat =>
selectedCategories.value.includes(cat)
)
// Apply AND/OR logic based on filter mode
if (filterMode.value === 'all') {
// AND logic: Product must have ALL selected categories
return matchingCategories.length === selectedCategories.value.length
} else {
// OR logic: Product must have ANY selected category
return matchingCategories.length > 0
}
})
})
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: 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) => {
// 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>