Add MarketFuzzySearch component for enhanced product searching

- Introduced a new MarketFuzzySearch component to provide an advanced search interface with keyboard shortcuts, search suggestions, and recent searches functionality.
- Updated MarketPage and StallView to integrate the new fuzzy search component, replacing the previous search input implementations.
- Enhanced search capabilities with configurable options for better user experience and product discovery.

These changes improve the search functionality across the market module, making it easier for users to find products efficiently.
This commit is contained in:
padreug 2025-09-25 23:02:47 +02:00
parent 86d3133978
commit 8aa575ffb1
3 changed files with 470 additions and 34 deletions

View file

@ -0,0 +1,359 @@
<template>
<div class="market-fuzzy-search" :class="props.class">
<!-- Enhanced Search Input with Keyboard Shortcuts -->
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search class="h-4 w-4 text-muted-foreground" />
</div>
<Input
ref="searchInputRef"
:model-value="searchQuery"
@update:model-value="handleSearchChange"
@keydown="handleKeydown"
:placeholder="enhancedPlaceholder"
:disabled="disabled"
class="pl-10 pr-20"
/>
<!-- Keyboard Shortcuts Hint -->
<div class="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
<div v-if="showKeyboardHints && !searchQuery" class="text-xs text-muted-foreground hidden sm:flex items-center gap-1">
<Badge variant="outline" class="px-1 py-0 text-xs"> K</Badge>
</div>
<!-- Clear Button -->
<Button
v-if="searchQuery"
variant="ghost"
size="sm"
@click="handleClear"
class="h-6 w-6 p-0 hover:bg-muted"
>
<X class="h-3 w-3" />
<span class="sr-only">Clear search</span>
</Button>
</div>
</div>
<!-- Advanced Search Results with Categories and Previews -->
<div v-if="isSearching && showResultCount" class="mt-2 flex items-center justify-between text-sm text-muted-foreground">
<span>{{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found</span>
<!-- Quick Filters -->
<div v-if="searchQuery && topCategories.length > 0" class="flex items-center gap-1">
<span class="text-xs">In:</span>
<Button
v-for="category in topCategories.slice(0, 3)"
:key="category"
variant="ghost"
size="sm"
@click="filterByCategory(category)"
class="h-5 px-1 text-xs"
>
{{ category }}
</Button>
</div>
</div>
<!-- Search Suggestions (when no input but focused) -->
<div v-if="showSuggestions && !searchQuery && isFocused" class="mt-2 p-3 border rounded-lg bg-muted/50">
<div class="text-sm font-medium mb-2">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"
>
{{ suggestion }}
</Button>
</div>
</div>
<!-- Recent Searches -->
<div v-if="showRecentSearches && recentSearches.length > 0 && !searchQuery && isFocused" class="mt-2 p-3 border rounded-lg bg-muted/50">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium">Recent searches:</div>
<Button
variant="ghost"
size="sm"
@click="clearRecentSearches"
class="h-5 px-1 text-xs"
>
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"
>
<History class="w-3 h-3 mr-1" />
{{ recent }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted } 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 { useLocalStorage } from '@vueuse/core'
import type { Product } from '../types/market'
interface Props {
/**
* The data to search through
*/
data: Product[]
/**
* Configuration options for the fuzzy search
*/
options?: FuzzySearchOptions<Product>
/**
* Placeholder text for the search input
*/
placeholder?: string
/**
* Whether to show keyboard hints
*/
showKeyboardHints?: boolean
/**
* Whether to show the result count
*/
showResultCount?: boolean
/**
* Whether to show search suggestions
*/
showSuggestions?: boolean
/**
* Whether to show recent searches
*/
showRecentSearches?: boolean
/**
* Custom class for the search container
*/
class?: string | string[]
/**
* Whether the search input should be disabled
*/
disabled?: boolean
}
interface Emits {
(e: 'update:modelValue', value: string): void
(e: 'search', query: string): void
(e: 'results', results: Product[]): void
(e: 'clear'): void
(e: 'filter-category', category: string): void
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Search products, stalls, categories...',
showKeyboardHints: true,
showResultCount: true,
showSuggestions: true,
showRecentSearches: true,
disabled: false
})
const emit = defineEmits<Emits>()
// Create reactive data ref for the composable
const dataRef = computed(() => props.data)
// Use the fuzzy search composable
const {
searchQuery,
filteredItems,
isSearching,
resultCount,
clearSearch,
setSearchQuery
} = useFuzzySearch(dataRef, props.options)
// Local state
const searchInputRef = ref<HTMLInputElement>()
const isFocused = ref(false)
// Persistent recent searches (stored in localStorage)
const recentSearches = useLocalStorage<string[]>('market-recent-searches', [])
// Enhanced placeholder with keyboard shortcut
const enhancedPlaceholder = computed(() => {
if (props.showKeyboardHints) {
return `${props.placeholder} (⌘K to focus)`
}
return props.placeholder
})
// Extract categories from search results for quick filters
const topCategories = computed(() => {
const categoryCount = new Map<string, number>()
filteredItems.value.forEach(product => {
product.categories?.forEach(category => {
categoryCount.set(category, (categoryCount.get(category) || 0) + 1)
})
})
return Array.from(categoryCount.entries())
.sort((a, b) => b[1] - a[1])
.map(([category]) => category)
})
// Generate search suggestions based on popular categories and products
const searchSuggestions = computed(() => {
const suggestions = new Set<string>()
// Add popular categories
const allCategories = new Map<string, number>()
props.data.forEach(product => {
product.categories?.forEach(category => {
allCategories.set(category, (allCategories.get(category) || 0) + 1)
})
})
Array.from(allCategories.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 4)
.forEach(([category]) => suggestions.add(category))
// Add popular stall names
const stallNames = new Map<string, number>()
props.data.forEach(product => {
if (product.stallName && product.stallName !== 'Unknown Stall') {
stallNames.set(product.stallName, (stallNames.get(product.stallName) || 0) + 1)
}
})
Array.from(stallNames.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 2)
.forEach(([stall]) => suggestions.add(stall))
return Array.from(suggestions)
})
// Methods
const handleSearchChange = (value: string | number) => {
const stringValue = String(value)
setSearchQuery(stringValue)
emit('update:modelValue', stringValue)
emit('search', stringValue)
emit('results', filteredItems.value)
// Add to recent searches when user finishes typing
if (stringValue.trim() && stringValue.length >= 3) {
addToRecentSearches(stringValue.trim())
}
}
const handleClear = () => {
clearSearch()
emit('update:modelValue', '')
emit('search', '')
emit('results', filteredItems.value)
emit('clear')
searchInputRef.value?.focus()
}
const handleKeydown = (event: KeyboardEvent) => {
// Escape key clears search
if (event.key === 'Escape') {
if (searchQuery.value) {
handleClear()
} else {
searchInputRef.value?.blur()
}
event.preventDefault()
}
}
const filterByCategory = (category: string) => {
emit('filter-category', category)
setSearchQuery(category)
}
const applySuggestion = (suggestion: string) => {
setSearchQuery(suggestion)
addToRecentSearches(suggestion)
}
const applyRecentSearch = (recent: string) => {
setSearchQuery(recent)
}
const addToRecentSearches = (query: string) => {
const searches = recentSearches.value.filter(s => s !== query)
searches.unshift(query)
recentSearches.value = searches.slice(0, 10) // Keep only 10 recent searches
}
const clearRecentSearches = () => {
recentSearches.value = []
}
// Focus handling
const handleFocus = () => {
isFocused.value = true
}
const handleBlur = () => {
// Delay hiding to allow clicking on suggestions
setTimeout(() => {
isFocused.value = false
}, 200)
}
// Keyboard shortcuts
const handleGlobalKeydown = (event: KeyboardEvent) => {
// K or Ctrl+K to focus search
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault()
searchInputRef.value?.focus()
}
}
// 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)
if (searchInputRef.value) {
searchInputRef.value.addEventListener('focus', handleFocus)
searchInputRef.value.addEventListener('blur', handleBlur)
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleGlobalKeydown)
if (searchInputRef.value) {
searchInputRef.value.removeEventListener('focus', handleFocus)
searchInputRef.value.removeEventListener('blur', handleBlur)
}
})
</script>
<style scoped>
.market-fuzzy-search {
@apply w-full relative;
}
</style>