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
162
src/modules/market/components/CategoryFilterBar.vue
Normal file
162
src/modules/market/components/CategoryFilterBar.vue
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<section
|
||||
v-if="categories.length > 0"
|
||||
:class="containerClass"
|
||||
:aria-labelledby="headingId"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2 sm:mb-3">
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<h3 :id="headingId" class="text-sm sm:text-lg font-semibold text-gray-700">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- AND/OR Filter Mode Toggle -->
|
||||
<div
|
||||
v-if="selectedCount > 1 && showFilterMode"
|
||||
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="$emit('set-filter-mode', '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="$emit('set-filter-mode', '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="selectedCount > 0"
|
||||
@click="$emit('clear-all')"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-xs sm:text-sm px-2 sm:px-3 py-1 sm:py-2"
|
||||
:aria-label="`Clear all ${selectedCount} selected category filters`"
|
||||
>
|
||||
<span class="hidden sm:inline">Clear All </span><span class="sm:hidden">Clear </span>({{ selectedCount }})
|
||||
<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 categories"
|
||||
: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="$emit('toggle-category', category.category)"
|
||||
@keydown.enter="$emit('toggle-category', category.category)"
|
||||
@keydown.space.prevent="$emit('toggle-category', 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="selectedCount > 0 && showProductCount"
|
||||
class="mt-2 text-center"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span class="text-xs sm:text-sm text-muted-foreground">
|
||||
{{ productCount }} products found
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X, Check } from 'lucide-vue-next'
|
||||
|
||||
interface CategoryData {
|
||||
category: string
|
||||
count: number
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
categories: CategoryData[]
|
||||
selectedCount: number
|
||||
filterMode: 'any' | 'all'
|
||||
productCount?: number
|
||||
title?: string
|
||||
showFilterMode?: boolean
|
||||
showProductCount?: boolean
|
||||
containerClass?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: 'Browse by Category',
|
||||
showFilterMode: true,
|
||||
showProductCount: true,
|
||||
containerClass: 'mb-4 sm:mb-6'
|
||||
})
|
||||
|
||||
const headingId = `category-filters-heading-${Math.random().toString(36).slice(2)}`
|
||||
|
||||
defineEmits<{
|
||||
'toggle-category': [category: string]
|
||||
'clear-all': []
|
||||
'set-filter-mode': [mode: 'any' | 'all']
|
||||
}>()
|
||||
</script>
|
||||
|
|
@ -58,67 +58,31 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Search Suggestions & Recent Searches -->
|
||||
<div
|
||||
v-if="(showSuggestions || showRecentSearches) && !searchQuery && isFocused"
|
||||
class="absolute top-full left-0 right-0 z-50 mt-1 max-h-80 overflow-y-auto bg-popover border border-border rounded-md shadow-lg"
|
||||
>
|
||||
<!-- Search Suggestions -->
|
||||
<div v-if="showSuggestions && searchSuggestions.length > 0" class="p-3 border-b border-border">
|
||||
<div class="text-sm font-medium mb-2 text-foreground">Try searching for:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="suggestion in searchSuggestions"
|
||||
:key="suggestion"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="applySuggestion(suggestion)"
|
||||
class="h-6 px-2 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
<div v-if="showRecentSearches && recentSearches.length > 0" class="p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-sm font-medium text-foreground">Recent searches:</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="clearRecentSearches"
|
||||
class="h-5 px-1 text-xs hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="recent in recentSearches.slice(0, 5)"
|
||||
:key="recent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="applyRecentSearch(recent)"
|
||||
class="h-6 px-2 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<History class="w-3 h-3 mr-1" />
|
||||
{{ recent }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Search Suggestions Dropdown -->
|
||||
<SearchSuggestions
|
||||
:show-suggestions="showSuggestions"
|
||||
:show-recent-searches="showRecentSearches"
|
||||
:search-query="searchQuery"
|
||||
:is-focused="isFocused"
|
||||
:suggestions="searchSuggestions"
|
||||
:recent-searches="recentSearches"
|
||||
@apply-suggestion="applySuggestion"
|
||||
@apply-recent="applyRecentSearch"
|
||||
@clear-recent="clearRecentSearches"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useFuzzySearch, type FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Search, X, History } from 'lucide-vue-next'
|
||||
import { Search, X } from 'lucide-vue-next'
|
||||
import SearchSuggestions from './SearchSuggestions.vue'
|
||||
import { useLocalStorage, useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||
import { useSearchKeyboardShortcuts } from '../composables/useSearchKeyboardShortcuts'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -281,26 +245,17 @@ const handleClear = () => {
|
|||
emit('clear')
|
||||
|
||||
// Focus the input after clearing
|
||||
if (searchInputRef.value?.$el) {
|
||||
const inputElement = searchInputRef.value.$el.querySelector('input') || searchInputRef.value.$el
|
||||
if (inputElement && typeof inputElement.focus === 'function') {
|
||||
inputElement.focus()
|
||||
}
|
||||
}
|
||||
focusSearchInput()
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
// Escape key clears search
|
||||
if (event.key === 'Escape') {
|
||||
const shouldClear = handleSearchKeydown(event)
|
||||
if (shouldClear) {
|
||||
if (searchQuery.value) {
|
||||
handleClear()
|
||||
} else if (searchInputRef.value?.$el) {
|
||||
const inputElement = searchInputRef.value.$el.querySelector('input') || searchInputRef.value.$el
|
||||
if (inputElement && typeof inputElement.blur === 'function') {
|
||||
inputElement.blur()
|
||||
}
|
||||
} else {
|
||||
blurSearchInput()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -340,35 +295,15 @@ const handleBlur = () => {
|
|||
}, 200)
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
const handleGlobalKeydown = (event: KeyboardEvent) => {
|
||||
// ⌘K or Ctrl+K to focus search
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
console.log('⌘K/Ctrl+K pressed, focusing search input', !!searchInputRef.value)
|
||||
if (searchInputRef.value?.$el) {
|
||||
// Access the underlying HTML input element from the Shadcn Input component
|
||||
const inputElement = searchInputRef.value.$el.querySelector('input') || searchInputRef.value.$el
|
||||
if (inputElement && typeof inputElement.focus === 'function') {
|
||||
inputElement.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Use keyboard shortcuts composable
|
||||
const { focusSearchInput, blurSearchInput, handleSearchKeydown } = useSearchKeyboardShortcuts(searchInputRef)
|
||||
|
||||
// Watch for changes in filtered items and emit results
|
||||
watch(filteredItems, (items) => {
|
||||
emit('results', items)
|
||||
}, { immediate: true })
|
||||
|
||||
// Setup and cleanup
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
// The keyboard shortcuts composable handles setup and cleanup
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
92
src/modules/market/components/ProductGrid.vue
Normal file
92
src/modules/market/components/ProductGrid.vue
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" 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">{{ loadingMessage }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="products.length === 0 && !isLoading" class="text-center py-12">
|
||||
<slot name="empty">
|
||||
<EmptyIcon class="w-24 h-24 text-muted-foreground/50 mx-auto mb-4" />
|
||||
<h3 class="text-xl font-semibold text-gray-600 mb-2">{{ emptyTitle }}</h3>
|
||||
<p class="text-gray-500">{{ emptyMessage }}</p>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Product Grid -->
|
||||
<div v-else :class="gridClasses">
|
||||
<ProductCard
|
||||
v-for="product in products"
|
||||
:key="product.id"
|
||||
:product="product as Product"
|
||||
@add-to-cart="$emit('add-to-cart', $event)"
|
||||
@view-details="$emit('view-details', $event)"
|
||||
@view-stall="$emit('view-stall', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Package as EmptyIcon } from 'lucide-vue-next'
|
||||
import ProductCard from './ProductCard.vue'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
interface Props {
|
||||
products: Product[]
|
||||
isLoading?: boolean
|
||||
loadingMessage?: string
|
||||
emptyTitle?: string
|
||||
emptyMessage?: string
|
||||
columns?: {
|
||||
mobile?: number
|
||||
sm?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
xl?: number
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
loadingMessage: 'Loading products...',
|
||||
emptyTitle: 'No products found',
|
||||
emptyMessage: 'Try adjusting your filters or search terms',
|
||||
columns: () => ({
|
||||
mobile: 1,
|
||||
sm: 2,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4
|
||||
})
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'add-to-cart': [product: Product]
|
||||
'view-details': [product: Product]
|
||||
'view-stall': [stallId: string]
|
||||
}>()
|
||||
|
||||
// Compute grid classes based on column configuration
|
||||
const gridClasses = computed(() => {
|
||||
const cols = props.columns
|
||||
const classes = ['grid', 'gap-6']
|
||||
|
||||
// Mobile columns
|
||||
if (cols.mobile === 1) classes.push('grid-cols-1')
|
||||
else if (cols.mobile === 2) classes.push('grid-cols-2')
|
||||
|
||||
// Responsive columns
|
||||
if (cols.sm) classes.push(`sm:grid-cols-${cols.sm}`)
|
||||
if (cols.md) classes.push(`md:grid-cols-${cols.md}`)
|
||||
if (cols.lg) classes.push(`lg:grid-cols-${cols.lg}`)
|
||||
if (cols.xl) classes.push(`xl:grid-cols-${cols.xl}`)
|
||||
|
||||
return classes.join(' ')
|
||||
})
|
||||
</script>
|
||||
76
src/modules/market/components/SearchSuggestions.vue
Normal file
76
src/modules/market/components/SearchSuggestions.vue
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="(showSuggestions || showRecentSearches) && !searchQuery && isFocused"
|
||||
class="absolute top-full left-0 right-0 z-50 mt-1 max-h-80 overflow-y-auto bg-popover border border-border rounded-md shadow-lg"
|
||||
>
|
||||
<!-- Search Suggestions -->
|
||||
<div v-if="showSuggestions && suggestions.length > 0" class="p-3 border-b border-border">
|
||||
<div class="text-sm font-medium mb-2 text-foreground">Try searching for:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="suggestion in suggestions"
|
||||
:key="suggestion"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('apply-suggestion', suggestion)"
|
||||
class="h-6 px-2 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{{ suggestion }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Searches -->
|
||||
<div v-if="showRecentSearches && recentSearches.length > 0" class="p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="text-sm font-medium text-foreground">Recent searches:</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('clear-recent')"
|
||||
class="h-5 px-1 text-xs hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="recent in recentSearches.slice(0, 5)"
|
||||
:key="recent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click="$emit('apply-recent', recent)"
|
||||
class="h-6 px-2 text-xs hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<History class="w-3 h-3 mr-1" />
|
||||
{{ recent }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { History } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
showSuggestions?: boolean
|
||||
showRecentSearches?: boolean
|
||||
searchQuery: string
|
||||
isFocused: boolean
|
||||
suggestions: string[]
|
||||
recentSearches: string[]
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
showSuggestions: true,
|
||||
showRecentSearches: true
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
'apply-suggestion': [suggestion: string]
|
||||
'apply-recent': [search: string]
|
||||
'clear-recent': []
|
||||
}>()
|
||||
</script>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -117,32 +117,18 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="flex justify-center items-center h-64">
|
||||
<Loader2 class="w-12 h-12 animate-spin text-primary" />
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else-if="!filteredProducts.length" class="text-center py-12">
|
||||
<Package class="w-24 h-24 text-muted-foreground/50 mx-auto mb-4" />
|
||||
<h2 class="text-xl font-semibold mb-2 text-foreground">No Products Found</h2>
|
||||
<p class="text-muted-foreground">
|
||||
{{ searchQuery || selectedCategories.length > 0
|
||||
? 'Try adjusting your filters or search terms'
|
||||
: 'This stall doesn\'t have any products yet' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Products Grid -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
<ProductCard
|
||||
v-for="product in filteredProducts"
|
||||
:key="product.id"
|
||||
:product="product as Product"
|
||||
@view-details="viewProductDetails"
|
||||
@view-stall="viewStall"
|
||||
/>
|
||||
</div>
|
||||
<!-- Products Grid with Loading and Empty States -->
|
||||
<ProductGrid
|
||||
:products="filteredProducts as Product[]"
|
||||
:is-loading="isLoading"
|
||||
loading-message="Loading stall products..."
|
||||
empty-title="No Products Found"
|
||||
:empty-message="searchQuery || selectedCategories.length > 0
|
||||
? 'Try adjusting your filters or search terms'
|
||||
: 'This stall doesn\'t have any products yet'"
|
||||
@view-details="viewProductDetails"
|
||||
@view-stall="viewStall"
|
||||
/>
|
||||
|
||||
<!-- Product Detail Dialog -->
|
||||
<ProductDetailDialog
|
||||
|
|
@ -169,9 +155,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Store, Package, Loader2, X } from 'lucide-vue-next'
|
||||
import { ArrowLeft, Store, X } from 'lucide-vue-next'
|
||||
import FuzzySearch from '@/components/ui/fuzzy-search/FuzzySearch.vue'
|
||||
import ProductCard from '../components/ProductCard.vue'
|
||||
import ProductGrid from '../components/ProductGrid.vue'
|
||||
import ProductDetailDialog from '../components/ProductDetailDialog.vue'
|
||||
import type { Product, Stall } from '../types/market'
|
||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue