- 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.
309 lines
10 KiB
Vue
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>
|