feat: extract and consolidate common UI patterns across market module
## Component Extraction
- Create MarketSearchBar component with dual-mode support (enhanced/simple)
- Enhanced mode: suggestions, keyboard shortcuts, category filters
- Simple mode: basic search functionality
- Dynamic imports for performance optimization
- Extract LoadingErrorState component for consistent loading/error handling
- Configurable compact/full modes with custom messages
- Built-in retry functionality
- Standardized spinner and error displays
- Consolidate CartButton component (already extracted in previous commit)
## UI Standardization
- Replace inline category badges in StallView with CategoryFilterBar component
- Add missing state management for category filtering (filterMode, setFilterMode)
- Ensure consistent filtering UI between MarketPage and StallView
- Standardize loading states across MarketPage, ProductGrid, and MerchantStore
## Code Organization
- MarketPage: Uses enhanced MarketSearchBar with full feature set
- StallView: Uses simple MarketSearchBar for cleaner stall-specific search
- Both views now share CategoryFilterBar, CartButton, and ProductGrid
- LoadingErrorState provides unified loading/error UX patterns
## Technical Improvements
- Eliminate code duplication following DRY principles
- Improve maintainability with single source of truth for UI patterns
- Optimize performance with conditional feature loading
- Enhance accessibility with consistent keyboard shortcuts and ARIA labels
- Ensure mobile-responsive designs with unified behavior
BREAKING CHANGE: MarketFuzzySearch component replaced by MarketSearchBar
This commit is contained in:
parent
8821f604be
commit
c8860dc937
6 changed files with 586 additions and 94 deletions
134
src/modules/market/components/LoadingErrorState.vue
Normal file
134
src/modules/market/components/LoadingErrorState.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
<template>
|
||||||
|
<div class="loading-error-state">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="isLoading" :class="[
|
||||||
|
'flex justify-center items-center',
|
||||||
|
fullHeight ? 'min-h-64' : 'py-12'
|
||||||
|
]">
|
||||||
|
<div class="flex flex-col items-center space-y-4">
|
||||||
|
<div :class="[
|
||||||
|
'rounded-full border-b-2 animate-spin',
|
||||||
|
compact ? 'h-8 w-8' : 'h-12 w-12',
|
||||||
|
spinnerColor || 'border-blue-600'
|
||||||
|
]"></div>
|
||||||
|
<p :class="[
|
||||||
|
compact ? 'text-sm' : 'text-base',
|
||||||
|
'text-gray-600'
|
||||||
|
]">
|
||||||
|
{{ loadingMessage || 'Loading...' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div v-else-if="hasError" :class="[
|
||||||
|
'flex justify-center items-center',
|
||||||
|
fullHeight ? 'min-h-64' : 'py-12'
|
||||||
|
]">
|
||||||
|
<div class="text-center">
|
||||||
|
<!-- Error Icon -->
|
||||||
|
<div v-if="!compact" class="w-16 h-16 mx-auto mb-4 bg-red-500/10 rounded-full flex items-center justify-center">
|
||||||
|
<AlertCircle class="w-8 h-8 text-red-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Content -->
|
||||||
|
<h2 :class="[
|
||||||
|
'font-bold text-red-600 mb-4',
|
||||||
|
compact ? 'text-lg' : 'text-2xl'
|
||||||
|
]">
|
||||||
|
{{ errorTitle || 'Error' }}
|
||||||
|
</h2>
|
||||||
|
<p :class="[
|
||||||
|
'text-gray-600 mb-4',
|
||||||
|
compact ? 'text-sm' : 'text-base'
|
||||||
|
]">
|
||||||
|
{{ errorMessage || 'Something went wrong' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Retry Button -->
|
||||||
|
<Button
|
||||||
|
v-if="showRetry"
|
||||||
|
@click="$emit('retry')"
|
||||||
|
variant="outline"
|
||||||
|
:size="compact ? 'sm' : 'default'"
|
||||||
|
>
|
||||||
|
{{ retryLabel || 'Try Again' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Success/Content State -->
|
||||||
|
<div v-else>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { AlertCircle } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Whether currently loading
|
||||||
|
*/
|
||||||
|
isLoading?: boolean
|
||||||
|
/**
|
||||||
|
* Loading message to display
|
||||||
|
*/
|
||||||
|
loadingMessage?: string
|
||||||
|
/**
|
||||||
|
* Whether there's an error
|
||||||
|
*/
|
||||||
|
hasError?: boolean
|
||||||
|
/**
|
||||||
|
* Error message to display
|
||||||
|
*/
|
||||||
|
errorMessage?: string
|
||||||
|
/**
|
||||||
|
* Error title/heading
|
||||||
|
*/
|
||||||
|
errorTitle?: string
|
||||||
|
/**
|
||||||
|
* Whether to show retry button
|
||||||
|
*/
|
||||||
|
showRetry?: boolean
|
||||||
|
/**
|
||||||
|
* Retry button label
|
||||||
|
*/
|
||||||
|
retryLabel?: string
|
||||||
|
/**
|
||||||
|
* Compact variant (smaller sizes)
|
||||||
|
*/
|
||||||
|
compact?: boolean
|
||||||
|
/**
|
||||||
|
* Use full height (min-h-64)
|
||||||
|
*/
|
||||||
|
fullHeight?: boolean
|
||||||
|
/**
|
||||||
|
* Custom spinner color class
|
||||||
|
*/
|
||||||
|
spinnerColor?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'retry'): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
isLoading: false,
|
||||||
|
hasError: false,
|
||||||
|
showRetry: true,
|
||||||
|
compact: false,
|
||||||
|
fullHeight: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.loading-error-state {
|
||||||
|
@apply w-full;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
372
src/modules/market/components/MarketSearchBar.vue
Normal file
372
src/modules/market/components/MarketSearchBar.vue
Normal file
|
|
@ -0,0 +1,372 @@
|
||||||
|
<template>
|
||||||
|
<div class="market-search-bar" :class="props.class">
|
||||||
|
<!-- Enhanced Search Input with Features -->
|
||||||
|
<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"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@blur="handleBlur"
|
||||||
|
:placeholder="computedPlaceholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="['pl-10', showEnhancements ? 'pr-20' : 'pr-10']"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Enhanced Features (keyboard hints, clear button) -->
|
||||||
|
<div class="absolute inset-y-0 right-0 pr-3 flex items-center gap-2">
|
||||||
|
<!-- Keyboard Shortcuts Hint (only for enhanced mode on desktop) -->
|
||||||
|
<div v-if="showEnhancements && showKeyboardHints && !searchQuery && isDesktop" class="text-xs text-muted-foreground 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>
|
||||||
|
|
||||||
|
<!-- Enhanced Features Section -->
|
||||||
|
<template v-if="showEnhancements">
|
||||||
|
<!-- Result Count with Quick Filters -->
|
||||||
|
<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 Category 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 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"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Basic Result Count (for simple mode) -->
|
||||||
|
<div v-else-if="showResultCount && isSearching" class="mt-2 text-sm text-muted-foreground">
|
||||||
|
{{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 } from 'lucide-vue-next'
|
||||||
|
import { useLocalStorage, useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||||
|
import type { Product } from '../types/market'
|
||||||
|
|
||||||
|
// Conditional imports for enhanced mode
|
||||||
|
const SearchSuggestions = ref()
|
||||||
|
const useSearchKeyboardShortcuts = ref()
|
||||||
|
|
||||||
|
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 enhanced features (suggestions, keyboard shortcuts, etc.)
|
||||||
|
*/
|
||||||
|
showEnhancements?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to show keyboard hints (only works with enhancements)
|
||||||
|
*/
|
||||||
|
showKeyboardHints?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to show the result count
|
||||||
|
*/
|
||||||
|
showResultCount?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to show search suggestions (only works with enhancements)
|
||||||
|
*/
|
||||||
|
showSuggestions?: boolean
|
||||||
|
/**
|
||||||
|
* Whether to show recent searches (only works with enhancements)
|
||||||
|
*/
|
||||||
|
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...',
|
||||||
|
showEnhancements: false,
|
||||||
|
showKeyboardHints: true,
|
||||||
|
showResultCount: true,
|
||||||
|
showSuggestions: true,
|
||||||
|
showRecentSearches: true,
|
||||||
|
disabled: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
// Dynamically import enhanced features only when needed
|
||||||
|
const initializeEnhancements = async () => {
|
||||||
|
if (props.showEnhancements) {
|
||||||
|
const [suggestionModule, shortcutModule] = await Promise.all([
|
||||||
|
import('./SearchSuggestions.vue'),
|
||||||
|
import('../composables/useSearchKeyboardShortcuts')
|
||||||
|
])
|
||||||
|
SearchSuggestions.value = suggestionModule.default
|
||||||
|
useSearchKeyboardShortcuts.value = shortcutModule.useSearchKeyboardShortcuts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize enhancements
|
||||||
|
initializeEnhancements()
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
const isFocused = ref(false)
|
||||||
|
|
||||||
|
// Enhanced features state (only initialized if showEnhancements is true)
|
||||||
|
const recentSearches = computed(() =>
|
||||||
|
props.showEnhancements ? useLocalStorage<string[]>('market-recent-searches', []) : ref([])
|
||||||
|
)
|
||||||
|
|
||||||
|
// Responsive breakpoints (only for enhanced mode)
|
||||||
|
const breakpoints = computed(() =>
|
||||||
|
props.showEnhancements ? useBreakpoints(breakpointsTailwind) : null
|
||||||
|
)
|
||||||
|
const isDesktop = computed(() =>
|
||||||
|
props.showEnhancements && breakpoints.value ? breakpoints.value.greaterOrEqual('lg') : ref(false)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Computed placeholder with keyboard shortcut hint
|
||||||
|
const computedPlaceholder = computed(() => {
|
||||||
|
if (props.showEnhancements && props.showKeyboardHints && isDesktop.value?.value) {
|
||||||
|
return `${props.placeholder} (⌘K to focus)`
|
||||||
|
}
|
||||||
|
return props.placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
// Extract categories from search results for quick filters (enhanced mode only)
|
||||||
|
const topCategories = computed(() => {
|
||||||
|
if (!props.showEnhancements) return []
|
||||||
|
|
||||||
|
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 (enhanced mode only)
|
||||||
|
const searchSuggestions = computed(() => {
|
||||||
|
if (!props.showEnhancements) return []
|
||||||
|
|
||||||
|
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 (enhanced mode only)
|
||||||
|
if (props.showEnhancements && stringValue.trim() && stringValue.length >= 3) {
|
||||||
|
addToRecentSearches(stringValue.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
clearSearch()
|
||||||
|
emit('update:modelValue', '')
|
||||||
|
emit('search', '')
|
||||||
|
emit('results', filteredItems.value)
|
||||||
|
emit('clear')
|
||||||
|
|
||||||
|
// Focus the input after clearing (enhanced mode only)
|
||||||
|
if (props.showEnhancements) {
|
||||||
|
focusSearchInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (!props.showEnhancements || !useSearchKeyboardShortcuts.value) return
|
||||||
|
|
||||||
|
const shouldClear = handleSearchKeydown(event)
|
||||||
|
if (shouldClear) {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
handleClear()
|
||||||
|
} else {
|
||||||
|
blurSearchInput()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
if (!props.showEnhancements) return
|
||||||
|
|
||||||
|
const searches = recentSearches.value.value.filter(s => s !== query)
|
||||||
|
searches.unshift(query)
|
||||||
|
recentSearches.value.value = searches.slice(0, 10) // Keep only 10 recent searches
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearRecentSearches = () => {
|
||||||
|
if (props.showEnhancements) {
|
||||||
|
recentSearches.value.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus handling
|
||||||
|
const handleFocus = () => {
|
||||||
|
isFocused.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
// Delay hiding to allow clicking on suggestions
|
||||||
|
setTimeout(() => {
|
||||||
|
isFocused.value = false
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhanced mode methods (initialized conditionally)
|
||||||
|
let focusSearchInput = () => {}
|
||||||
|
let blurSearchInput = () => {}
|
||||||
|
let handleSearchKeydown = () => false
|
||||||
|
|
||||||
|
// Initialize keyboard shortcuts for enhanced mode
|
||||||
|
watch(() => props.showEnhancements, async (showEnhancements) => {
|
||||||
|
if (showEnhancements && useSearchKeyboardShortcuts.value) {
|
||||||
|
const shortcuts = useSearchKeyboardShortcuts.value(searchInputRef)
|
||||||
|
focusSearchInput = shortcuts.focusSearchInput
|
||||||
|
blurSearchInput = shortcuts.blurSearchInput
|
||||||
|
handleSearchKeydown = shortcuts.handleSearchKeydown
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Watch for changes in filtered items and emit results
|
||||||
|
watch(filteredItems, (items) => {
|
||||||
|
emit('results', items)
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.market-search-bar {
|
||||||
|
@apply w-full relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,28 +1,16 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Loading State -->
|
<LoadingErrorState
|
||||||
<div v-if="isLoadingMerchant" class="flex flex-col items-center justify-center py-12">
|
:is-loading="isLoadingMerchant"
|
||||||
<div class="w-16 h-16 mx-auto mb-4 bg-muted/50 rounded-full flex items-center justify-center">
|
loading-message="Loading your merchant profile..."
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
:has-error="!!merchantCheckError"
|
||||||
</div>
|
error-title="Error Loading Merchant Status"
|
||||||
<h3 class="text-lg font-medium text-foreground mb-2">Checking Merchant Status</h3>
|
:error-message="merchantCheckError || ''"
|
||||||
<p class="text-muted-foreground">Loading your merchant profile...</p>
|
@retry="checkMerchantProfile"
|
||||||
</div>
|
:full-height="false"
|
||||||
|
>
|
||||||
<!-- Error State -->
|
|
||||||
<div v-else-if="merchantCheckError" class="flex flex-col items-center justify-center py-12">
|
|
||||||
<div class="w-16 h-16 mx-auto mb-4 bg-red-500/10 rounded-full flex items-center justify-center">
|
|
||||||
<AlertCircle class="w-8 h-8 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-medium text-foreground mb-2">Error Loading Merchant Status</h3>
|
|
||||||
<p class="text-muted-foreground mb-4">{{ merchantCheckError }}</p>
|
|
||||||
<Button @click="checkMerchantProfile" variant="outline">
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- No Merchant Profile Empty State -->
|
<!-- No Merchant Profile Empty State -->
|
||||||
<div v-else-if="!userHasMerchantProfile" class="flex flex-col items-center justify-center py-12">
|
<div v-if="!userHasMerchantProfile" class="flex flex-col items-center justify-center py-12">
|
||||||
<div class="w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6">
|
<div class="w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6">
|
||||||
<User class="w-12 h-12 text-muted-foreground" />
|
<User class="w-12 h-12 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -325,6 +313,9 @@
|
||||||
@created="onProductCreated"
|
@created="onProductCreated"
|
||||||
@updated="onProductUpdated"
|
@updated="onProductUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
</LoadingErrorState>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -333,6 +324,7 @@ import { useRouter } from 'vue-router'
|
||||||
import { useMarketStore } from '@/modules/market/stores/market'
|
import { useMarketStore } from '@/modules/market/stores/market'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import LoadingErrorState from './LoadingErrorState.vue'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Store,
|
Store,
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="product-grid-container">
|
<div class="product-grid-container">
|
||||||
<!-- Loading State -->
|
<LoadingErrorState
|
||||||
<div v-if="isLoading" class="flex justify-center items-center min-h-64">
|
:is-loading="isLoading"
|
||||||
<div class="flex flex-col items-center space-y-4">
|
:loading-message="loadingMessage"
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
:has-error="false"
|
||||||
<p class="text-gray-600">{{ loadingMessage }}</p>
|
:full-height="false"
|
||||||
</div>
|
>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div v-else-if="products.length === 0 && !isLoading" class="text-center py-12">
|
<div v-if="products.length === 0" class="text-center py-12">
|
||||||
<slot name="empty">
|
<slot name="empty">
|
||||||
<EmptyIcon class="w-24 h-24 text-muted-foreground/50 mx-auto mb-4" />
|
<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>
|
<h3 class="text-xl font-semibold text-gray-600 mb-2">{{ emptyTitle }}</h3>
|
||||||
|
|
@ -37,6 +35,7 @@
|
||||||
@close="closeProductDetail"
|
@close="closeProductDetail"
|
||||||
@add-to-cart="handleDialogAddToCart"
|
@add-to-cart="handleDialogAddToCart"
|
||||||
/>
|
/>
|
||||||
|
</LoadingErrorState>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -45,6 +44,7 @@ import { computed, ref } from 'vue'
|
||||||
import { Package as EmptyIcon } from 'lucide-vue-next'
|
import { Package as EmptyIcon } from 'lucide-vue-next'
|
||||||
import ProductCard from './ProductCard.vue'
|
import ProductCard from './ProductCard.vue'
|
||||||
import ProductDetailDialog from './ProductDetailDialog.vue'
|
import ProductDetailDialog from './ProductDetailDialog.vue'
|
||||||
|
import LoadingErrorState from './LoadingErrorState.vue'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<!-- Loading State -->
|
<LoadingErrorState
|
||||||
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
|
:is-loading="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)"
|
||||||
<div class="flex flex-col items-center space-y-4">
|
:loading-message="marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...'"
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
:has-error="!!(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0"
|
||||||
<p class="text-gray-600">
|
error-title="Failed to load market"
|
||||||
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
|
:error-message="marketStore.error || marketPreloader.preloadError || ''"
|
||||||
</p>
|
@retry="retryLoadMarket"
|
||||||
</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 -->
|
<!-- Market Header - Optimized for Mobile -->
|
||||||
<div class="mb-4 sm:mb-6 lg:mb-8">
|
<div class="mb-4 sm:mb-6 lg:mb-8">
|
||||||
<!-- Market Info and Search - Responsive Layout -->
|
<!-- Market Info and Search - Responsive Layout -->
|
||||||
|
|
@ -43,11 +28,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Enhanced Fuzzy Search Bar - Full Width on Mobile -->
|
<!-- Enhanced Search Bar - Full Width on Mobile -->
|
||||||
<div class="w-full lg:flex-1 lg:max-w-md">
|
<div class="w-full lg:flex-1 lg:max-w-md">
|
||||||
<MarketFuzzySearch
|
<MarketSearchBar
|
||||||
:data="marketStore.products as Product[]"
|
:data="marketStore.products as Product[]"
|
||||||
:options="searchOptions"
|
:options="searchOptions"
|
||||||
|
:show-enhancements="true"
|
||||||
@results="handleSearchResults"
|
@results="handleSearchResults"
|
||||||
@filter-category="handleCategoryFilter"
|
@filter-category="handleCategoryFilter"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
|
|
@ -81,11 +67,10 @@
|
||||||
@view-stall="viewStall"
|
@view-stall="viewStall"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cart Summary -->
|
<!-- Cart Summary -->
|
||||||
<CartButton />
|
<CartButton />
|
||||||
|
|
||||||
|
</LoadingErrorState>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -99,10 +84,11 @@ import { useCategoryFilter } from '../composables/useCategoryFilter'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import MarketFuzzySearch from '../components/MarketFuzzySearch.vue'
|
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||||
import ProductGrid from '../components/ProductGrid.vue'
|
import ProductGrid from '../components/ProductGrid.vue'
|
||||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
import CartButton from '../components/CartButton.vue'
|
import CartButton from '../components/CartButton.vue'
|
||||||
|
import LoadingErrorState from '../components/LoadingErrorState.vue'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,35 +58,33 @@
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Categories (Enhanced) -->
|
|
||||||
<div v-if="stallCategories.length > 0" class="flex flex-wrap gap-1">
|
|
||||||
<Badge
|
|
||||||
v-for="category in stallCategories"
|
|
||||||
:key="category"
|
|
||||||
:variant="selectedCategories.includes(category) ? 'default' : 'secondary'"
|
|
||||||
class="text-xs px-2 py-0.5 cursor-pointer transition-all duration-200 hover:scale-105 hover:shadow-sm"
|
|
||||||
:class="{
|
|
||||||
'bg-gradient-to-r from-primary to-primary/90 text-primary-foreground shadow-md': selectedCategories.includes(category),
|
|
||||||
'hover:bg-primary/10 hover:border-primary/50': !selectedCategories.includes(category)
|
|
||||||
}"
|
|
||||||
@click="toggleCategoryFilter(category)"
|
|
||||||
>
|
|
||||||
{{ category }}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Category Filter Bar -->
|
||||||
|
<CategoryFilterBar
|
||||||
|
v-if="stallCategories.length > 0"
|
||||||
|
:categories="stallCategories"
|
||||||
|
:selected-categories="selectedCategories"
|
||||||
|
:filter-mode="filterMode"
|
||||||
|
title="Categories"
|
||||||
|
@toggle-category="toggleCategoryFilter"
|
||||||
|
@set-filter-mode="setFilterMode"
|
||||||
|
@clear-filters="clearCategoryFilters"
|
||||||
|
class="mb-4 sm:mb-6"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Search and Filter Bar -->
|
<!-- Search and Filter Bar -->
|
||||||
<div class="mb-4 sm:mb-6 flex flex-col sm:flex-row gap-2 sm:gap-4">
|
<div class="mb-4 sm:mb-6 flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<FuzzySearch
|
<MarketSearchBar
|
||||||
:data="stallProducts"
|
:data="stallProducts"
|
||||||
:options="searchOptions"
|
:options="searchOptions"
|
||||||
placeholder="Search products in this stall..."
|
placeholder="Search products in this stall..."
|
||||||
|
:show-enhancements="false"
|
||||||
@results="handleSearchResults"
|
@results="handleSearchResults"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
|
|
@ -151,9 +149,10 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { ArrowLeft, Store, X } from 'lucide-vue-next'
|
import { ArrowLeft, Store, X } from 'lucide-vue-next'
|
||||||
import FuzzySearch from '@/components/ui/fuzzy-search/FuzzySearch.vue'
|
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||||
import ProductGrid from '../components/ProductGrid.vue'
|
import ProductGrid from '../components/ProductGrid.vue'
|
||||||
import CartButton from '../components/CartButton.vue'
|
import CartButton from '../components/CartButton.vue'
|
||||||
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||||
import type { Product, Stall } from '../types/market'
|
import type { Product, Stall } from '../types/market'
|
||||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||||
|
|
||||||
|
|
@ -167,6 +166,7 @@ const searchResults = ref<Product[]>([])
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const sortBy = ref('name')
|
const sortBy = ref('name')
|
||||||
const selectedCategories = ref<string[]>([])
|
const selectedCategories = ref<string[]>([])
|
||||||
|
const filterMode = ref<'any' | 'all'>('any')
|
||||||
const logoError = ref(false)
|
const logoError = ref(false)
|
||||||
|
|
||||||
// Fuzzy search configuration for stall products
|
// Fuzzy search configuration for stall products
|
||||||
|
|
@ -260,6 +260,14 @@ const toggleCategoryFilter = (category: string) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setFilterMode = (mode: 'any' | 'all') => {
|
||||||
|
filterMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearCategoryFilters = () => {
|
||||||
|
selectedCategories.value = []
|
||||||
|
}
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
selectedCategories.value = []
|
selectedCategories.value = []
|
||||||
searchResults.value = []
|
searchResults.value = []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue