Merge branch 'marketplace'
This commit is contained in:
commit
e062dfe2b8
26 changed files with 3651 additions and 253 deletions
351
src/components/ui/ProgressiveImage.vue
Normal file
351
src/components/ui/ProgressiveImage.vue
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
<template>
|
||||
<div class="progressive-image-container" :class="containerClass" :style="containerStyle">
|
||||
<!-- Blur placeholder background -->
|
||||
<div v-if="!isLoaded"
|
||||
:class="['progressive-image-placeholder', { 'shimmer-active': !isLoaded && !hasError }]"
|
||||
:style="placeholderStyle">
|
||||
<!-- Branded shimmer effect using semantic colors -->
|
||||
<div v-if="!isLoaded && !hasError" class="absolute inset-0 bg-gradient-to-r from-accent/40 via-primary/40 via-accent/70 to-accent/50 animate-pulse"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main image -->
|
||||
<img ref="imageRef" :src="src" :alt="alt" :class="[
|
||||
'progressive-image',
|
||||
imageClass,
|
||||
{
|
||||
'progressive-image-loading': !isLoaded,
|
||||
'progressive-image-loaded': isLoaded,
|
||||
'progressive-image-error': hasError
|
||||
}
|
||||
]" :loading="loading" @load="handleLoad" @error="handleError" /> -->
|
||||
|
||||
<!-- Loading indicator (optional) -->
|
||||
<div v-if="showLoadingIndicator && !isLoaded && !hasError" class="progressive-image-loading-indicator">
|
||||
<div class="progressive-image-spinner" />
|
||||
</div>
|
||||
|
||||
<!-- Error or no image state (optional) -->
|
||||
<div v-if="hasError && showErrorState" class="progressive-image-error-state">
|
||||
<slot name="error">
|
||||
<div class="progressive-image-error-content">
|
||||
<Package class="w-6 h-6 text-muted-foreground" />
|
||||
<span class="text-xs text-muted-foreground">{{ errorMessage }}</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Package } from 'lucide-vue-next'
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
* The main image source URL
|
||||
*/
|
||||
src: string
|
||||
|
||||
/**
|
||||
* Alt text for the image
|
||||
*/
|
||||
alt: string
|
||||
|
||||
/**
|
||||
* Optional base64 thumbnail for blur effect
|
||||
* If not provided, will use CSS blur with background color
|
||||
*/
|
||||
thumbnail?: string
|
||||
|
||||
/**
|
||||
* Blur radius for the placeholder (in pixels)
|
||||
*/
|
||||
blurRadius?: number
|
||||
|
||||
/**
|
||||
* Background color for the placeholder when no thumbnail is provided
|
||||
*/
|
||||
backgroundColor?: string
|
||||
|
||||
/**
|
||||
* Duration of the fade-in transition (in milliseconds)
|
||||
*/
|
||||
transitionDuration?: number
|
||||
|
||||
/**
|
||||
* Loading strategy for the image
|
||||
*/
|
||||
loading?: 'lazy' | 'eager'
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the container
|
||||
*/
|
||||
containerClass?: string | string[]
|
||||
|
||||
/**
|
||||
* Additional CSS classes for the image
|
||||
*/
|
||||
imageClass?: string | string[]
|
||||
|
||||
/**
|
||||
* Whether to show a loading spinner
|
||||
*/
|
||||
showLoadingIndicator?: boolean
|
||||
|
||||
/**
|
||||
* Whether to show error state when image fails to load
|
||||
*/
|
||||
showErrorState?: boolean
|
||||
|
||||
/**
|
||||
* Custom container styles
|
||||
*/
|
||||
containerStyle?: Record<string, string>
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'load', event: Event): void
|
||||
(e: 'error', event: Event): void
|
||||
(e: 'loading-start'): void
|
||||
(e: 'loading-complete'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
blurRadius: 10,
|
||||
backgroundColor: 'hsl(var(--muted))',
|
||||
transitionDuration: 300,
|
||||
loading: 'lazy',
|
||||
showLoadingIndicator: false,
|
||||
showErrorState: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Reactive state
|
||||
const imageRef = ref<HTMLImageElement>()
|
||||
const isLoaded = ref(false)
|
||||
const hasError = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
// Check if this is a placeholder/no-image URL
|
||||
const isNoImage = computed(() => {
|
||||
return props.src.includes('/placeholder-product.png') ||
|
||||
props.src === '' ||
|
||||
!props.src
|
||||
})
|
||||
|
||||
// Dynamic error message based on whether image exists
|
||||
const errorMessage = computed(() => {
|
||||
if (isNoImage.value) {
|
||||
return 'No image available'
|
||||
}
|
||||
return 'Failed to load'
|
||||
})
|
||||
|
||||
// Computed styles
|
||||
const placeholderStyle = computed(() => {
|
||||
const hasThumb = props.thumbnail && props.thumbnail.trim()
|
||||
|
||||
// Create a subtle gradient pattern when no thumbnail (theme-aware)
|
||||
const gradientBg = hasThumb
|
||||
? 'none'
|
||||
: `linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.5) 50%, hsl(var(--muted)) 100%)`
|
||||
|
||||
return {
|
||||
'--blur-radius': `${props.blurRadius}px`,
|
||||
'--transition-duration': `${props.transitionDuration}ms`,
|
||||
'--placeholder-bg': gradientBg,
|
||||
backgroundImage: hasThumb ? `url(${props.thumbnail})` : gradientBg,
|
||||
backgroundColor: hasThumb ? 'transparent' : props.backgroundColor,
|
||||
filter: hasThumb ? `blur(${props.blurRadius}px)` : 'none',
|
||||
transform: hasThumb ? 'scale(1.1)' : 'scale(1)', // Only scale if using thumbnail
|
||||
}
|
||||
})
|
||||
|
||||
// Note: generatePlaceholder function can be added here if needed for dynamic thumbnail generation
|
||||
|
||||
// Event handlers
|
||||
const handleLoad = (event: Event) => {
|
||||
isLoaded.value = true
|
||||
isLoading.value = false
|
||||
emit('load', event)
|
||||
emit('loading-complete')
|
||||
}
|
||||
|
||||
const handleError = (event: Event) => {
|
||||
hasError.value = true
|
||||
isLoading.value = false
|
||||
emit('error', event)
|
||||
emit('loading-complete')
|
||||
}
|
||||
|
||||
// Initialize loading state
|
||||
onMounted(() => {
|
||||
if (imageRef.value && !imageRef.value.complete) {
|
||||
isLoading.value = true
|
||||
emit('loading-start')
|
||||
} else if (imageRef.value?.complete) {
|
||||
isLoaded.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({
|
||||
reload: () => {
|
||||
if (imageRef.value) {
|
||||
isLoaded.value = false
|
||||
hasError.value = false
|
||||
isLoading.value = true
|
||||
imageRef.value.src = props.src
|
||||
emit('loading-start')
|
||||
}
|
||||
},
|
||||
isLoaded: () => isLoaded.value,
|
||||
hasError: () => hasError.value,
|
||||
isLoading: () => isLoading.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progressive-image-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressive-image-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
transition: opacity var(--transition-duration, 300ms) ease-out;
|
||||
}
|
||||
|
||||
/* Add shimmer effect when image is loading - follows Tailwind animation patterns */
|
||||
.progressive-image-placeholder.shimmer-active {
|
||||
position: relative;
|
||||
background: linear-gradient(135deg, hsl(var(--muted)) 0%, hsl(var(--muted) / 0.5) 50%, hsl(var(--muted)) 100%) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Accessibility: Respect user's reduced motion preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.shimmer-overlay {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.progressive-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: opacity var(--transition-duration, 300ms) ease-out;
|
||||
}
|
||||
|
||||
.progressive-image-loading {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progressive-image-loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.progressive-image-error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Hide placeholder when image is loaded */
|
||||
.progressive-image-loaded + .progressive-image-placeholder {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progressive-image-loading-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.progressive-image-spinner {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border: 2px solid;
|
||||
border-color: hsl(var(--primary));
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.progressive-image-error-state {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, hsl(var(--muted) / 0.5), hsl(var(--muted)));
|
||||
}
|
||||
|
||||
.progressive-image-error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions */
|
||||
.progressive-image-container * {
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Keyframe animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* DEBUG: Enhanced keyframes for testing */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.progressive-image-loaded {
|
||||
animation: fadeInScale var(--transition-duration, 300ms) ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
23
src/modules/market/components/CartButton.vue
Normal file
23
src/modules/market/components/CartButton.vue
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<!-- Cart Summary Button -->
|
||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4 z-50">
|
||||
<Button @click="viewCart" class="shadow-lg">
|
||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
||||
Cart ({{ marketStore.totalCartItems }})
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const viewCart = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -106,6 +107,7 @@
|
|||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-16 h-16 object-cover rounded-md"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||
:alt="item.product.name"
|
||||
class="w-8 h-8 object-cover rounded"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
||||
|
|
|
|||
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>
|
||||
286
src/modules/market/components/CategoryInput.vue
Normal file
286
src/modules/market/components/CategoryInput.vue
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- Category Input with Suggestions -->
|
||||
<div class="relative">
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
v-model="currentInput"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@keydown.enter.prevent="addCategory"
|
||||
@keydown.comma.prevent="addCategory"
|
||||
@input="handleInput"
|
||||
@focus="showSuggestions = true"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@click="addCategory"
|
||||
:disabled="disabled || !canAdd"
|
||||
size="sm"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Category Suggestions Dropdown -->
|
||||
<div
|
||||
v-if="showSuggestions && filteredSuggestions.length > 0"
|
||||
class="absolute top-full left-0 right-0 z-50 mt-1 max-h-48 overflow-y-auto bg-popover border border-border rounded-md shadow-lg"
|
||||
role="listbox"
|
||||
aria-label="Category suggestions"
|
||||
>
|
||||
<button
|
||||
v-for="(suggestion, index) in filteredSuggestions"
|
||||
:key="suggestion.category"
|
||||
type="button"
|
||||
@click="selectSuggestion(suggestion)"
|
||||
@keydown.enter="selectSuggestion(suggestion)"
|
||||
class="w-full px-3 py-2 text-left hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground text-sm flex items-center justify-between"
|
||||
role="option"
|
||||
:aria-selected="index === selectedSuggestionIndex"
|
||||
>
|
||||
<span>{{ suggestion.category }}</span>
|
||||
<Badge variant="secondary" class="text-xs">
|
||||
{{ suggestion.count }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Categories -->
|
||||
<div v-if="modelValue.length > 0" class="space-y-2">
|
||||
<div class="text-sm font-medium text-foreground">Selected Categories:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="(category, index) in modelValue"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
class="flex items-center gap-1 pl-2 pr-1 py-1"
|
||||
>
|
||||
<span>{{ category }}</span>
|
||||
<Button
|
||||
type="button"
|
||||
@click="removeCategory(index)"
|
||||
:disabled="disabled"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-4 w-4 p-0 hover:bg-destructive hover:text-destructive-foreground"
|
||||
:aria-label="`Remove ${category} category`"
|
||||
>
|
||||
<X class="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popular Categories Section -->
|
||||
<div v-if="showPopularCategories && popularCategories.length > 0" class="space-y-2">
|
||||
<div class="text-sm font-medium text-foreground">Popular Categories:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="category in popularCategories"
|
||||
:key="category.category"
|
||||
type="button"
|
||||
@click="selectSuggestion(category)"
|
||||
:disabled="disabled || modelValue.includes(category.category)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 px-2 text-xs"
|
||||
>
|
||||
{{ category.category }} ({{ category.count }})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Helper Text -->
|
||||
<div class="text-xs text-muted-foreground">
|
||||
Press Enter or comma to add a category. Maximum {{ maxCategories }} categories.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
|
||||
interface CategorySuggestion {
|
||||
category: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: string[]
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
maxCategories?: number
|
||||
showPopularCategories?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
placeholder: 'Enter category name...',
|
||||
maxCategories: 10,
|
||||
showPopularCategories: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// Local state
|
||||
const currentInput = ref('')
|
||||
const showSuggestions = ref(false)
|
||||
const selectedSuggestionIndex = ref(-1)
|
||||
|
||||
// Get existing categories from the market store
|
||||
const existingCategories = computed<CategorySuggestion[]>(() => {
|
||||
const categoryMap = new Map<string, number>()
|
||||
|
||||
marketStore.products.forEach(product => {
|
||||
product.categories?.forEach(cat => {
|
||||
if (cat && cat.trim()) {
|
||||
const normalizedCat = cat.toLowerCase().trim()
|
||||
categoryMap.set(normalizedCat, (categoryMap.get(normalizedCat) || 0) + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(categoryMap.entries())
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count) // Sort by popularity
|
||||
})
|
||||
|
||||
// Filter suggestions based on current input
|
||||
const filteredSuggestions = computed(() => {
|
||||
if (!currentInput.value.trim()) return []
|
||||
|
||||
const inputLower = currentInput.value.toLowerCase().trim()
|
||||
|
||||
return existingCategories.value
|
||||
.filter(suggestion =>
|
||||
suggestion.category.toLowerCase().includes(inputLower) &&
|
||||
!props.modelValue.includes(suggestion.category)
|
||||
)
|
||||
.slice(0, 8) // Limit to 8 suggestions
|
||||
})
|
||||
|
||||
// Popular categories (top 6 most used)
|
||||
const popularCategories = computed(() => {
|
||||
return existingCategories.value
|
||||
.filter(cat => !props.modelValue.includes(cat.category))
|
||||
.slice(0, 6)
|
||||
})
|
||||
|
||||
// Check if we can add the current input
|
||||
const canAdd = computed(() => {
|
||||
const trimmed = currentInput.value.trim().toLowerCase()
|
||||
return trimmed.length > 0 &&
|
||||
!props.modelValue.some(cat => cat.toLowerCase() === trimmed) &&
|
||||
props.modelValue.length < props.maxCategories
|
||||
})
|
||||
|
||||
// Methods
|
||||
const addCategory = () => {
|
||||
if (!canAdd.value) return
|
||||
|
||||
const category = currentInput.value.trim()
|
||||
const newCategories = [...props.modelValue, category]
|
||||
emit('update:modelValue', newCategories)
|
||||
|
||||
currentInput.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
const selectSuggestion = (suggestion: CategorySuggestion) => {
|
||||
if (props.modelValue.includes(suggestion.category)) return
|
||||
if (props.modelValue.length >= props.maxCategories) return
|
||||
|
||||
const newCategories = [...props.modelValue, suggestion.category]
|
||||
emit('update:modelValue', newCategories)
|
||||
|
||||
currentInput.value = ''
|
||||
showSuggestions.value = false
|
||||
}
|
||||
|
||||
const removeCategory = (index: number) => {
|
||||
const newCategories = props.modelValue.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', newCategories)
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
showSuggestions.value = true
|
||||
selectedSuggestionIndex.value = -1
|
||||
}
|
||||
|
||||
// Hide suggestions when clicking outside
|
||||
const hideSuggestions = () => {
|
||||
// Delay to allow click events on suggestions to fire
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false
|
||||
}, 200)
|
||||
}
|
||||
|
||||
// Handle keyboard navigation in suggestions
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!showSuggestions.value || filteredSuggestions.value.length === 0) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
selectedSuggestionIndex.value = Math.min(
|
||||
selectedSuggestionIndex.value + 1,
|
||||
filteredSuggestions.value.length - 1
|
||||
)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
selectedSuggestionIndex.value = Math.max(selectedSuggestionIndex.value - 1, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
event.preventDefault()
|
||||
if (selectedSuggestionIndex.value >= 0) {
|
||||
selectSuggestion(filteredSuggestions.value[selectedSuggestionIndex.value])
|
||||
} else {
|
||||
addCategory()
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
showSuggestions.value = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for keyboard navigation
|
||||
watch(currentInput, () => {
|
||||
selectedSuggestionIndex.value = -1
|
||||
})
|
||||
|
||||
// Setup global event listeners
|
||||
nextTick(() => {
|
||||
document.addEventListener('click', hideSuggestions)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add smooth transitions */
|
||||
.category-suggestions-enter-active,
|
||||
.category-suggestions-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.category-suggestions-enter-from,
|
||||
.category-suggestions-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -89,8 +89,9 @@
|
|||
<FormControl>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
:checked="value"
|
||||
@update:checked="handleChange"
|
||||
:key="`active-checkbox-${props.isOpen}`"
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
:disabled="isCreating"
|
||||
/>
|
||||
<Label>Product is active and visible</Label>
|
||||
|
|
@ -104,14 +105,20 @@
|
|||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<FormField name="categories">
|
||||
<FormField v-slot="{ value, handleChange }" name="categories">
|
||||
<FormItem>
|
||||
<FormLabel>Categories</FormLabel>
|
||||
<FormDescription>Add categories to help customers find your product</FormDescription>
|
||||
<div class="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">Category management coming soon</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<CategoryInput
|
||||
:model-value="value || []"
|
||||
@update:model-value="handleChange"
|
||||
:disabled="isCreating"
|
||||
placeholder="Enter category (e.g., electronics, clothing, books...)"
|
||||
:max-categories="10"
|
||||
:show-popular-categories="true"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -136,8 +143,8 @@
|
|||
<div class="flex items-center space-x-2">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
:checked="value"
|
||||
@update:checked="handleChange"
|
||||
:model-value="value"
|
||||
@update:model-value="handleChange"
|
||||
:disabled="isCreating"
|
||||
/>
|
||||
</FormControl>
|
||||
|
|
@ -202,6 +209,7 @@ import { Input } from '@/components/ui/input'
|
|||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import CategoryInput from './CategoryInput.vue'
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
|
|
@ -211,7 +219,8 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Package } from 'lucide-vue-next'
|
||||
import type { NostrmarketAPI, Stall, Product, CreateProductRequest } from '../services/nostrmarketAPI'
|
||||
import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
|
||||
import type { Product } from '../types/market'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
|
|
@ -318,9 +327,9 @@ const updateProduct = async (formData: any) => {
|
|||
createError.value = null
|
||||
|
||||
try {
|
||||
const productData: Product = {
|
||||
id: props.product.id,
|
||||
stall_id: props.product.stall_id,
|
||||
const productData = {
|
||||
id: props.product?.id,
|
||||
stall_id: props.product?.stall_id || props.stall?.id || '',
|
||||
name,
|
||||
categories: categories || [],
|
||||
images: images || [],
|
||||
|
|
@ -330,11 +339,13 @@ const updateProduct = async (formData: any) => {
|
|||
pending: false,
|
||||
config: {
|
||||
description: description || '',
|
||||
currency: props.stall?.currency || props.product.config.currency,
|
||||
currency: props.stall?.currency || props.product?.config?.currency || 'sats',
|
||||
use_autoreply,
|
||||
autoreply_message: use_autoreply ? autoreply_message || '' : '',
|
||||
shipping: props.product.config.shipping || []
|
||||
}
|
||||
shipping: props.product?.config?.shipping || []
|
||||
},
|
||||
event_id: props.product?.nostrEventId,
|
||||
event_created_at: props.product?.createdAt
|
||||
}
|
||||
|
||||
const adminKey = paymentService.getPreferredWalletAdminKey()
|
||||
|
|
@ -455,13 +466,13 @@ watch(() => props.isOpen, async (isOpen) => {
|
|||
use_autoreply: false,
|
||||
autoreply_message: ''
|
||||
}
|
||||
|
||||
|
||||
// Reset form with appropriate initial values
|
||||
resetForm({ values: initialValues })
|
||||
|
||||
|
||||
// Wait for reactivity
|
||||
await nextTick()
|
||||
|
||||
|
||||
// Clear any previous errors
|
||||
createError.value = null
|
||||
}
|
||||
|
|
|
|||
133
src/modules/market/components/LoadingErrorState.vue
Normal file
133
src/modules/market/components/LoadingErrorState.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<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 { 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
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
showRetry: true,
|
||||
compact: false,
|
||||
fullHeight: true
|
||||
})
|
||||
|
||||
defineEmits<Emits>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-error-state {
|
||||
@apply w-full;
|
||||
}
|
||||
</style>
|
||||
313
src/modules/market/components/MarketFuzzySearch.vue
Normal file
313
src/modules/market/components/MarketFuzzySearch.vue
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<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"
|
||||
@focus="handleFocus"
|
||||
@blur="handleBlur"
|
||||
: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 && 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>
|
||||
|
||||
<!-- 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 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 } 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 SearchSuggestions from './SearchSuggestions.vue'
|
||||
import { useLocalStorage, useBreakpoints, breakpointsTailwind } from '@vueuse/core'
|
||||
import { useSearchKeyboardShortcuts } from '../composables/useSearchKeyboardShortcuts'
|
||||
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()
|
||||
const isFocused = ref(false)
|
||||
|
||||
// Persistent recent searches (stored in localStorage)
|
||||
const recentSearches = useLocalStorage<string[]>('market-recent-searches', [])
|
||||
|
||||
// Responsive breakpoints
|
||||
const breakpoints = useBreakpoints(breakpointsTailwind)
|
||||
const isDesktop = breakpoints.greaterOrEqual('lg')
|
||||
|
||||
// Enhanced placeholder with keyboard shortcut (only on desktop)
|
||||
const enhancedPlaceholder = computed(() => {
|
||||
if (props.showKeyboardHints && isDesktop.value) {
|
||||
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')
|
||||
|
||||
// Focus the input after clearing
|
||||
focusSearchInput()
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
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) => {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
// The keyboard shortcuts composable handles setup and cleanup
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.market-fuzzy-search {
|
||||
@apply w-full relative;
|
||||
}
|
||||
</style>
|
||||
390
src/modules/market/components/MarketSearchBar.vue
Normal file
390
src/modules/market/components/MarketSearchBar.vue
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
<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="internalQuery || 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 && !internalQuery && !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="internalQuery || 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, useDebounceFn } from '@vueuse/core'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
// Conditional imports for enhanced mode
|
||||
import SearchSuggestions from './SearchSuggestions.vue'
|
||||
import { useSearchKeyboardShortcuts } from '../composables/useSearchKeyboardShortcuts'
|
||||
|
||||
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>()
|
||||
|
||||
// Enhanced features imported statically for better performance
|
||||
|
||||
// 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 = props.showEnhancements
|
||||
? useLocalStorage<string[]>('market-recent-searches', [])
|
||||
: ref<string[]>([])
|
||||
|
||||
// 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
|
||||
// Internal search query for immediate UI updates
|
||||
const internalQuery = ref('')
|
||||
|
||||
// Debounced search function for performance optimization
|
||||
const debouncedSearch = useDebounceFn((value: string) => {
|
||||
setSearchQuery(value)
|
||||
emit('search', value)
|
||||
emit('results', filteredItems.value)
|
||||
|
||||
// Add to recent searches when user finishes typing (enhanced mode only)
|
||||
if (props.showEnhancements && value.trim() && value.length >= 3) {
|
||||
addToRecentSearches(value.trim())
|
||||
}
|
||||
}, 300)
|
||||
|
||||
const handleSearchChange = (value: string | number) => {
|
||||
const stringValue = String(value)
|
||||
|
||||
// Update internal query immediately for responsive UI
|
||||
internalQuery.value = stringValue
|
||||
emit('update:modelValue', stringValue)
|
||||
|
||||
// Debounce the actual search computation
|
||||
debouncedSearch(stringValue)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
// Clear both internal and actual search queries
|
||||
internalQuery.value = ''
|
||||
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) return
|
||||
|
||||
// Handle basic Escape key for simple mode
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
if (internalQuery.value || searchQuery.value) {
|
||||
handleClear()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use enhanced keyboard shortcuts if available
|
||||
if (handleSearchKeydown) {
|
||||
const shouldClear = handleSearchKeydown(event)
|
||||
if (shouldClear) {
|
||||
if (internalQuery.value || searchQuery.value) {
|
||||
handleClear()
|
||||
} else {
|
||||
blurSearchInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filterByCategory = (category: string) => {
|
||||
emit('filter-category', category)
|
||||
internalQuery.value = category
|
||||
setSearchQuery(category)
|
||||
}
|
||||
|
||||
const applySuggestion = (suggestion: string) => {
|
||||
internalQuery.value = suggestion
|
||||
setSearchQuery(suggestion)
|
||||
addToRecentSearches(suggestion)
|
||||
}
|
||||
|
||||
const applyRecentSearch = (recent: string) => {
|
||||
internalQuery.value = recent
|
||||
setSearchQuery(recent)
|
||||
}
|
||||
|
||||
const addToRecentSearches = (query: string) => {
|
||||
if (!props.showEnhancements) return
|
||||
|
||||
const currentSearches = recentSearches.value as string[]
|
||||
const searches = currentSearches.filter((s: string) => s !== query)
|
||||
searches.unshift(query)
|
||||
recentSearches.value = searches.slice(0, 10) as any // Keep only 10 recent searches
|
||||
}
|
||||
|
||||
const clearRecentSearches = () => {
|
||||
if (props.showEnhancements) {
|
||||
recentSearches.value = [] as any
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = (_event: KeyboardEvent) => false
|
||||
|
||||
// Initialize keyboard shortcuts for enhanced mode
|
||||
watch(() => props.showEnhancements, async (showEnhancements) => {
|
||||
if (showEnhancements && useSearchKeyboardShortcuts) {
|
||||
const shortcuts = useSearchKeyboardShortcuts(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,56 +1,56 @@
|
|||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoadingMerchant" class="flex flex-col items-center justify-center py-12">
|
||||
<div class="w-16 h-16 mx-auto mb-4 bg-muted/50 rounded-full flex items-center justify-center">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<div v-if="isLoadingMerchant" class="flex justify-center items-center py-12">
|
||||
<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">Loading your merchant profile...</p>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-2">Checking Merchant Status</h3>
|
||||
<p class="text-muted-foreground">Loading your merchant profile...</p>
|
||||
</div>
|
||||
|
||||
<!-- 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 v-else-if="merchantCheckError" class="flex justify-center items-center py-12">
|
||||
<div class="text-center">
|
||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Error Loading Merchant Status</h2>
|
||||
<p class="text-gray-600 mb-4">{{ merchantCheckError }}</p>
|
||||
<Button @click="checkMerchantProfile" variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</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 -->
|
||||
<div v-else-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">
|
||||
<User class="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-2">Create Your Merchant Profile</h2>
|
||||
<p class="text-muted-foreground text-center mb-6 max-w-md">
|
||||
Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
|
||||
</p>
|
||||
<Button
|
||||
@click="createMerchantProfile"
|
||||
variant="default"
|
||||
size="lg"
|
||||
:disabled="isCreatingMerchant"
|
||||
>
|
||||
<div v-if="isCreatingMerchant" class="flex items-center">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<span>Creating...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<Plus class="w-5 h-5 mr-2" />
|
||||
<span>Create Merchant Profile</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stores Grid (shown when merchant profile exists) -->
|
||||
<!-- Content -->
|
||||
<div v-else>
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<!-- No Merchant Profile Empty State -->
|
||||
<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">
|
||||
<User class="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-foreground mb-2">Create Your Merchant Profile</h2>
|
||||
<p class="text-muted-foreground text-center mb-6 max-w-md">
|
||||
Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
|
||||
</p>
|
||||
<Button
|
||||
@click="createMerchantProfile"
|
||||
variant="default"
|
||||
size="lg"
|
||||
:disabled="isCreatingMerchant"
|
||||
>
|
||||
<div v-if="isCreatingMerchant" class="flex items-center">
|
||||
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<span>Creating...</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center">
|
||||
<Plus class="w-5 h-5 mr-2" />
|
||||
<span>Create Merchant Profile</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stores Grid (shown when merchant profile exists) -->
|
||||
<div v-else>
|
||||
<!-- Header Section -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-foreground">My Stores</h2>
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
<div class="space-y-3">
|
||||
<div>
|
||||
<h4 class="font-semibold text-foreground">{{ product.name }}</h4>
|
||||
<p v-if="product.config.description" class="text-sm text-muted-foreground mt-1">
|
||||
<p v-if="product.config?.description" class="text-sm text-muted-foreground mt-1">
|
||||
{{ product.config.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -266,7 +266,7 @@
|
|||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<span class="text-lg font-bold text-foreground">
|
||||
{{ product.price }} {{ product.config.currency || activeStall?.currency || 'sat' }}
|
||||
{{ product.price }} {{ product.config?.currency || activeStall?.currency || 'sat' }}
|
||||
</span>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Qty: {{ product.quantity }}
|
||||
|
|
@ -309,22 +309,23 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Store Dialog -->
|
||||
<CreateStoreDialog
|
||||
:is-open="showCreateStoreDialog"
|
||||
@close="showCreateStoreDialog = false"
|
||||
@created="onStoreCreated"
|
||||
/>
|
||||
<!-- Create Store Dialog -->
|
||||
<CreateStoreDialog
|
||||
:is-open="showCreateStoreDialog"
|
||||
@close="showCreateStoreDialog = false"
|
||||
@created="onStoreCreated"
|
||||
/>
|
||||
|
||||
<!-- Create Product Dialog -->
|
||||
<CreateProductDialog
|
||||
:is-open="showCreateProductDialog"
|
||||
:stall="activeStall"
|
||||
:product="editingProduct"
|
||||
@close="closeProductDialog"
|
||||
@created="onProductCreated"
|
||||
@updated="onProductUpdated"
|
||||
/>
|
||||
<!-- Create Product Dialog -->
|
||||
<CreateProductDialog
|
||||
:is-open="showCreateProductDialog"
|
||||
:stall="activeStall"
|
||||
:product="editingProduct"
|
||||
@close="closeProductDialog"
|
||||
@created="onProductCreated"
|
||||
@updated="onProductUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -333,16 +334,17 @@ import { useRouter } from 'vue-router'
|
|||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
DollarSign,
|
||||
Star,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
import {
|
||||
Package,
|
||||
Store,
|
||||
DollarSign,
|
||||
Star,
|
||||
Plus,
|
||||
User
|
||||
} from 'lucide-vue-next'
|
||||
import type { NostrmarketAPI, Merchant, Stall, Product } from '../services/nostrmarketAPI'
|
||||
import type { NostrmarketAPI, Merchant, Stall } from '../services/nostrmarketAPI'
|
||||
import type { Product } from '../types/market'
|
||||
import { mapApiResponseToProduct } from '../types/market'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
|
|
@ -516,7 +518,22 @@ const loadStallProducts = async () => {
|
|||
inkey,
|
||||
activeStall.value.id!
|
||||
)
|
||||
stallProducts.value = products || []
|
||||
// Convert API responses to domain models using clean mapping function
|
||||
const enrichedProducts = (products || []).map(product =>
|
||||
mapApiResponseToProduct(
|
||||
product,
|
||||
activeStall.value?.name || 'Unknown Stall',
|
||||
activeStall.value?.currency || 'sats'
|
||||
)
|
||||
)
|
||||
stallProducts.value = enrichedProducts
|
||||
|
||||
// Only add active products to the market store so they appear in the main market
|
||||
enrichedProducts
|
||||
.filter(product => product.active)
|
||||
.forEach(product => {
|
||||
marketStore.addProduct(product)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load products:', error)
|
||||
stallProducts.value = []
|
||||
|
|
@ -623,4 +640,4 @@ watch(() => activeStallId.value, async (newStallId) => {
|
|||
stallProducts.value = []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,12 +2,27 @@
|
|||
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
||||
<!-- Product Image -->
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="product.images?.[0] || '/placeholder-product.png'"
|
||||
<!-- Show actual image if available -->
|
||||
<ProgressiveImage
|
||||
v-if="product.images?.[0]"
|
||||
:src="product.images[0]"
|
||||
:alt="product.name"
|
||||
class="w-full h-48 object-cover"
|
||||
container-class="w-full h-48 bg-muted/50"
|
||||
image-class="w-full h-48 object-cover"
|
||||
:blur-radius="8"
|
||||
:transition-duration="400"
|
||||
loading="lazy"
|
||||
:show-loading-indicator="false"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
|
||||
<!-- Show placeholder when no image -->
|
||||
<div v-else class="w-full h-48 bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<Package class="w-12 h-12 mx-auto text-muted-foreground mb-2" />
|
||||
<span class="text-xs text-muted-foreground">No image available</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<Button
|
||||
|
|
@ -101,11 +116,11 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||
import { ShoppingCart, Package } from 'lucide-vue-next'
|
||||
import type { Product } from '@/modules/market/stores/market'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -114,16 +129,16 @@ interface Props {
|
|||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// const emit = defineEmits<{
|
||||
// 'view-details': [product: Product]
|
||||
// 'view-stall': [stallId: string]
|
||||
// }>()
|
||||
const emit = defineEmits<{
|
||||
'add-to-cart': [product: Product]
|
||||
'view-details': [product: Product]
|
||||
'view-stall': [stallId: string]
|
||||
}>()
|
||||
|
||||
const marketStore = useMarketStore()
|
||||
const imageError = ref(false)
|
||||
|
||||
const addToCart = () => {
|
||||
marketStore.addToStallCart(props.product, 1)
|
||||
emit('add-to-cart', props.product)
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
|
|
|
|||
280
src/modules/market/components/ProductDetailDialog.vue
Normal file
280
src/modules/market/components/ProductDetailDialog.vue
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
<template>
|
||||
<Dialog :open="isOpen" @update:open="handleClose">
|
||||
<DialogContent class="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<!-- Close Button -->
|
||||
<DialogClose class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X class="h-4 w-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Product Images -->
|
||||
<div class="space-y-4">
|
||||
<!-- Main Image -->
|
||||
<div class="aspect-square rounded-lg overflow-hidden bg-gray-100">
|
||||
<ProgressiveImage
|
||||
v-if="currentImage"
|
||||
:src="currentImage"
|
||||
:alt="product.name"
|
||||
container-class="w-full h-full"
|
||||
image-class="w-full h-full object-cover"
|
||||
:blur-radius="12"
|
||||
:transition-duration="500"
|
||||
loading="lazy"
|
||||
:show-loading-indicator="true"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div v-else class="w-full h-full bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<Package class="w-24 h-24 mx-auto text-muted-foreground mb-4" />
|
||||
<span class="text-sm text-muted-foreground">No image available</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Thumbnails -->
|
||||
<div v-if="productImages.length > 1" class="flex gap-2 overflow-x-auto">
|
||||
<button
|
||||
v-for="(image, index) in productImages"
|
||||
:key="index"
|
||||
@click="currentImageIndex = index"
|
||||
class="relative w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
|
||||
:class="currentImageIndex === index ? 'border-primary' : 'border-gray-200 hover:border-gray-400'"
|
||||
>
|
||||
<ProgressiveImage
|
||||
:src="image"
|
||||
:alt="`${product.name} - Image ${index + 1}`"
|
||||
container-class="w-full h-full"
|
||||
image-class="w-full h-full object-cover"
|
||||
:blur-radius="6"
|
||||
:transition-duration="300"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
<div class="space-y-6">
|
||||
<!-- Title and Price -->
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold mb-2">{{ product.name }}</h2>
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span class="text-3xl font-bold text-green-600">
|
||||
{{ formatPrice(product.price, product.currency) }}
|
||||
</span>
|
||||
<Badge v-if="product.quantity < 1" variant="destructive">
|
||||
Out of Stock
|
||||
</Badge>
|
||||
<Badge v-else-if="product.quantity <= 5" variant="outline">
|
||||
Only {{ product.quantity }} left
|
||||
</Badge>
|
||||
<Badge v-else variant="secondary">
|
||||
In Stock
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stall Info -->
|
||||
<div class="flex items-center gap-2 pb-4 border-b">
|
||||
<Store class="w-4 h-4 text-gray-500" />
|
||||
<span class="text-sm text-gray-600">Sold by</span>
|
||||
<span class="font-medium">{{ product.stallName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div v-if="product.description">
|
||||
<h3 class="font-semibold mb-2">Description</h3>
|
||||
<p class="text-gray-600 whitespace-pre-wrap">{{ product.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Categories -->
|
||||
<div v-if="product.categories && product.categories.length > 0">
|
||||
<h3 class="font-semibold mb-2">Categories</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="category in product.categories"
|
||||
:key="category"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ category }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add to Cart Section -->
|
||||
<div class="space-y-4 pt-6 border-t">
|
||||
<div class="flex items-center gap-4">
|
||||
<Label for="quantity" class="text-sm font-medium">
|
||||
Quantity:
|
||||
</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
@click="decrementQuantity"
|
||||
:disabled="quantity <= 1"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 w-8 p-0"
|
||||
>
|
||||
<Minus class="h-4 w-4" />
|
||||
</Button>
|
||||
<Input
|
||||
id="quantity"
|
||||
v-model.number="quantity"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="product.quantity || 999"
|
||||
class="w-16 h-8 text-center"
|
||||
/>
|
||||
<Button
|
||||
@click="incrementQuantity"
|
||||
:disabled="quantity >= (product.quantity || 999)"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 w-8 p-0"
|
||||
>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button
|
||||
@click="handleAddToCart"
|
||||
:disabled="product.quantity < 1"
|
||||
class="flex-1"
|
||||
>
|
||||
<ShoppingCart class="w-4 h-4 mr-2" />
|
||||
Add to Cart
|
||||
</Button>
|
||||
<Button
|
||||
@click="handleClose"
|
||||
variant="outline"
|
||||
class="flex-1"
|
||||
>
|
||||
Continue Shopping
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogClose,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||
import { Package, ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'add-to-cart': [product: Product, quantity: number]
|
||||
}>()
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Local state
|
||||
const quantity = ref(1)
|
||||
const currentImageIndex = ref(0)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const productImages = computed(() => {
|
||||
if (!props.product.images || props.product.images.length === 0) {
|
||||
return []
|
||||
}
|
||||
return props.product.images.filter(img => img && img.trim() !== '')
|
||||
})
|
||||
|
||||
const currentImage = computed(() => {
|
||||
if (productImages.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
return productImages.value[currentImageIndex.value]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
if (currency === 'sat' || currency === 'sats') {
|
||||
return `${price.toLocaleString('en-US')} sats`
|
||||
}
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase()
|
||||
}).format(price)
|
||||
}
|
||||
|
||||
const incrementQuantity = () => {
|
||||
const max = props.product.quantity || 999
|
||||
if (quantity.value < max) {
|
||||
quantity.value++
|
||||
}
|
||||
}
|
||||
|
||||
const decrementQuantity = () => {
|
||||
if (quantity.value > 1) {
|
||||
quantity.value--
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToCart = () => {
|
||||
emit('add-to-cart', props.product, quantity.value)
|
||||
toast.success(`Added ${quantity.value} ${props.product.name} to cart`)
|
||||
handleClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
imageLoadError.value = true
|
||||
}
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
quantity.value = 1
|
||||
currentImageIndex.value = 0
|
||||
imageLoadError.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// Reset image index when product changes
|
||||
watch(() => props.product?.id, () => {
|
||||
currentImageIndex.value = 0
|
||||
imageLoadError.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Hide number input spinner buttons */
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
127
src/modules/market/components/ProductGrid.vue
Normal file
127
src/modules/market/components/ProductGrid.vue
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
<template>
|
||||
<div class="product-grid-container">
|
||||
<LoadingErrorState
|
||||
:is-loading="isLoading"
|
||||
:loading-message="loadingMessage"
|
||||
:has-error="false"
|
||||
:full-height="false"
|
||||
>
|
||||
<!-- Empty State -->
|
||||
<div v-if="products.length === 0" 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="handleAddToCart"
|
||||
@view-details="handleViewDetails"
|
||||
@view-stall="$emit('view-stall', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Detail Dialog - Now managed internally -->
|
||||
<ProductDetailDialog
|
||||
v-if="selectedProduct"
|
||||
:product="selectedProduct"
|
||||
:isOpen="showProductDetail"
|
||||
@close="closeProductDetail"
|
||||
@add-to-cart="handleDialogAddToCart"
|
||||
/>
|
||||
</LoadingErrorState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Package as EmptyIcon } from 'lucide-vue-next'
|
||||
import ProductCard from './ProductCard.vue'
|
||||
import ProductDetailDialog from './ProductDetailDialog.vue'
|
||||
import LoadingErrorState from './LoadingErrorState.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
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'add-to-cart': [product: Product, quantity?: number]
|
||||
'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(' ')
|
||||
})
|
||||
|
||||
// Internal state for product detail dialog
|
||||
const showProductDetail = ref(false)
|
||||
const selectedProduct = ref<Product | null>(null)
|
||||
|
||||
// Handle view details internally
|
||||
const handleViewDetails = (product: Product) => {
|
||||
selectedProduct.value = product
|
||||
showProductDetail.value = true
|
||||
}
|
||||
|
||||
const closeProductDetail = () => {
|
||||
showProductDetail.value = false
|
||||
selectedProduct.value = null
|
||||
}
|
||||
|
||||
// Handle add to cart from product card (quick add, quantity 1)
|
||||
const handleAddToCart = (product: Product) => {
|
||||
emit('add-to-cart', product, 1)
|
||||
}
|
||||
|
||||
// Handle add to cart from dialog (with custom quantity)
|
||||
const handleDialogAddToCart = (product: Product, quantity: number) => {
|
||||
emit('add-to-cart', product, quantity)
|
||||
closeProductDetail()
|
||||
}
|
||||
</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>
|
||||
193
src/modules/market/composables/useCategoryFilter.ts
Normal file
193
src/modules/market/composables/useCategoryFilter.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import { ref, computed, readonly, type Ref } from 'vue'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
export interface CategoryItem {
|
||||
category: string
|
||||
count: number
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export interface CategoryFilterOptions {
|
||||
caseSensitive?: boolean
|
||||
includeEmpty?: boolean
|
||||
minCount?: number
|
||||
mode?: 'any' | 'all' // OR vs AND logic
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable for category filtering functionality
|
||||
* Provides reactive category management with optimized performance
|
||||
*/
|
||||
export function useCategoryFilter(
|
||||
products: Ref<Product[]>,
|
||||
options: CategoryFilterOptions = {}
|
||||
) {
|
||||
// Use Set for O(1) lookups instead of array
|
||||
const selectedCategories = ref<Set<string>>(new Set())
|
||||
|
||||
// Filter mode state (reactive)
|
||||
const filterMode = ref<'any' | 'all'>(options.mode || 'any')
|
||||
|
||||
// Computed property for all available categories with counts
|
||||
const allCategories = computed<CategoryItem[]>(() => {
|
||||
const categoryMap = new Map<string, number>()
|
||||
|
||||
// Count categories across all products
|
||||
products.value.forEach(product => {
|
||||
product.categories?.forEach(cat => {
|
||||
if (cat && cat.trim()) {
|
||||
const category = options.caseSensitive ? cat : cat.toLowerCase()
|
||||
categoryMap.set(category, (categoryMap.get(category) || 0) + 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Convert to CategoryItem array with selection state
|
||||
return Array.from(categoryMap.entries())
|
||||
.filter(([_, count]) => count >= (options.minCount || 1))
|
||||
.map(([category, count]) => ({
|
||||
category,
|
||||
count,
|
||||
selected: selectedCategories.value.has(category)
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count) // Sort by popularity
|
||||
})
|
||||
|
||||
// Optimized product filtering with AND/OR logic
|
||||
const filteredProducts = computed<Product[]>(() => {
|
||||
const selectedSet = selectedCategories.value
|
||||
|
||||
// Early return if no filters
|
||||
if (selectedSet.size === 0) {
|
||||
return products.value
|
||||
}
|
||||
|
||||
return products.value.filter(product => {
|
||||
// Handle empty categories
|
||||
if (!product.categories?.length) {
|
||||
return options.includeEmpty || false
|
||||
}
|
||||
|
||||
// Normalize product categories
|
||||
const productCategories = product.categories
|
||||
.filter(cat => cat && cat.trim())
|
||||
.map(cat => options.caseSensitive ? cat : cat.toLowerCase())
|
||||
|
||||
if (productCategories.length === 0) {
|
||||
return options.includeEmpty || false
|
||||
}
|
||||
|
||||
// Count matches between product categories and selected categories
|
||||
const matchingCategories = productCategories.filter(cat =>
|
||||
selectedSet.has(cat)
|
||||
)
|
||||
|
||||
// Apply AND/OR logic
|
||||
if (filterMode.value === 'all') {
|
||||
// AND logic: Product must have ALL selected categories
|
||||
return matchingCategories.length === selectedSet.size
|
||||
} else {
|
||||
// OR logic: Product must have ANY selected category
|
||||
return matchingCategories.length > 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Computed properties for UI state
|
||||
const selectedCount = computed(() => selectedCategories.value.size)
|
||||
|
||||
const selectedCategoryNames = computed(() =>
|
||||
Array.from(selectedCategories.value)
|
||||
)
|
||||
|
||||
const hasActiveFilters = computed(() => selectedCategories.value.size > 0)
|
||||
|
||||
// Actions with optimized reactivity
|
||||
const toggleCategory = (category: string) => {
|
||||
const normalizedCategory = options.caseSensitive ? category : category.toLowerCase()
|
||||
const currentSet = selectedCategories.value
|
||||
|
||||
// Create new Set to maintain reactivity (more efficient than copying)
|
||||
if (currentSet.has(normalizedCategory)) {
|
||||
const newSet = new Set(currentSet)
|
||||
newSet.delete(normalizedCategory)
|
||||
selectedCategories.value = newSet
|
||||
} else {
|
||||
const newSet = new Set(currentSet)
|
||||
newSet.add(normalizedCategory)
|
||||
selectedCategories.value = newSet
|
||||
}
|
||||
}
|
||||
|
||||
const addCategory = (category: string) => {
|
||||
const normalizedCategory = options.caseSensitive ? category : category.toLowerCase()
|
||||
const newSet = new Set(selectedCategories.value)
|
||||
newSet.add(normalizedCategory)
|
||||
selectedCategories.value = newSet
|
||||
}
|
||||
|
||||
const removeCategory = (category: string) => {
|
||||
const normalizedCategory = options.caseSensitive ? category : category.toLowerCase()
|
||||
const newSet = new Set(selectedCategories.value)
|
||||
newSet.delete(normalizedCategory)
|
||||
selectedCategories.value = newSet
|
||||
}
|
||||
|
||||
const clearAllCategories = () => {
|
||||
selectedCategories.value = new Set() // Create new empty Set
|
||||
}
|
||||
|
||||
const selectMultipleCategories = (categories: string[]) => {
|
||||
const newSet = new Set(selectedCategories.value)
|
||||
categories.forEach(cat => {
|
||||
const normalizedCategory = options.caseSensitive ? cat : cat.toLowerCase()
|
||||
newSet.add(normalizedCategory)
|
||||
})
|
||||
selectedCategories.value = newSet
|
||||
}
|
||||
|
||||
const isSelected = (category: string): boolean => {
|
||||
const normalizedCategory = options.caseSensitive ? category : category.toLowerCase()
|
||||
return selectedCategories.value.has(normalizedCategory)
|
||||
}
|
||||
|
||||
const setFilterMode = (mode: 'any' | 'all') => {
|
||||
filterMode.value = mode
|
||||
}
|
||||
|
||||
const toggleFilterMode = () => {
|
||||
filterMode.value = filterMode.value === 'any' ? 'all' : 'any'
|
||||
}
|
||||
|
||||
// Category statistics
|
||||
const categoryStats = computed(() => ({
|
||||
totalCategories: allCategories.value.length,
|
||||
selectedCategories: selectedCategories.value.size,
|
||||
filteredProductCount: filteredProducts.value.length,
|
||||
totalProductCount: products.value.length
|
||||
}))
|
||||
|
||||
return {
|
||||
// State (readonly to prevent external mutation)
|
||||
selectedCategories: readonly(selectedCategories),
|
||||
filterMode: readonly(filterMode),
|
||||
allCategories,
|
||||
filteredProducts,
|
||||
selectedCount,
|
||||
selectedCategoryNames,
|
||||
hasActiveFilters,
|
||||
categoryStats,
|
||||
|
||||
// Actions
|
||||
toggleCategory,
|
||||
addCategory,
|
||||
removeCategory,
|
||||
clearAllCategories,
|
||||
selectMultipleCategories,
|
||||
isSelected,
|
||||
setFilterMode,
|
||||
toggleFilterMode
|
||||
}
|
||||
}
|
||||
|
||||
export default useCategoryFilter
|
||||
|
|
@ -286,24 +286,37 @@ export function useMarket() {
|
|||
productGroups.forEach((productEvents, productId) => {
|
||||
// Sort by created_at and take the most recent
|
||||
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
|
||||
|
||||
|
||||
try {
|
||||
const productData = JSON.parse(latestEvent.content)
|
||||
const stallId = productData.stall_id || 'unknown'
|
||||
|
||||
// Extract categories from Nostr event tags (standard approach)
|
||||
const categories = latestEvent.tags
|
||||
.filter((tag: any) => tag[0] === 't')
|
||||
.map((tag: any) => tag[1])
|
||||
.filter((cat: string) => cat && cat.trim())
|
||||
|
||||
|
||||
// Look up the stall name from the stalls array
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
const stallName = stall?.name || 'Unknown Stall'
|
||||
|
||||
const product = {
|
||||
id: productId,
|
||||
stall_id: productData.stall_id || 'unknown',
|
||||
stallName: productData.stallName || 'Unknown Stall',
|
||||
stall_id: stallId,
|
||||
stallName: stallName,
|
||||
name: productData.name || 'Unnamed Product',
|
||||
description: productData.description || '',
|
||||
price: productData.price || 0,
|
||||
currency: productData.currency || 'sats',
|
||||
quantity: productData.quantity || 1,
|
||||
images: productData.images || [],
|
||||
categories: productData.categories || [],
|
||||
categories: categories,
|
||||
createdAt: latestEvent.created_at,
|
||||
updatedAt: latestEvent.created_at
|
||||
}
|
||||
|
||||
|
||||
marketStore.addProduct(product)
|
||||
} catch (err) {
|
||||
// Silently handle parse errors
|
||||
|
|
@ -468,10 +481,22 @@ export function useMarket() {
|
|||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (productId) {
|
||||
const productData = JSON.parse(event.content)
|
||||
const stallId = productData.stall_id || 'unknown'
|
||||
|
||||
// Extract categories from Nostr event tags (standard approach)
|
||||
const categories = event.tags
|
||||
.filter((tag: any) => tag[0] === 't')
|
||||
.map((tag: any) => tag[1])
|
||||
.filter((cat: string) => cat && cat.trim())
|
||||
|
||||
// Look up the stall name from the stalls array
|
||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||
const stallName = stall?.name || 'Unknown Stall'
|
||||
|
||||
const product = {
|
||||
id: productId,
|
||||
stall_id: productData.stall_id || 'unknown',
|
||||
stallName: productData.stallName || 'Unknown Stall',
|
||||
stall_id: stallId,
|
||||
stallName: stallName,
|
||||
pubkey: event.pubkey,
|
||||
name: productData.name || 'Unnamed Product',
|
||||
description: productData.description || '',
|
||||
|
|
@ -479,11 +504,11 @@ export function useMarket() {
|
|||
currency: productData.currency || 'sats',
|
||||
quantity: productData.quantity || 1,
|
||||
images: productData.images || [],
|
||||
categories: productData.categories || [],
|
||||
categories: categories,
|
||||
createdAt: event.created_at,
|
||||
updatedAt: event.created_at
|
||||
}
|
||||
|
||||
|
||||
marketStore.addProduct(product)
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
|
|||
53
src/modules/market/composables/useSearchKeyboardShortcuts.ts
Normal file
53
src/modules/market/composables/useSearchKeyboardShortcuts.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { onMounted, onUnmounted, type Ref } from 'vue'
|
||||
|
||||
export function useSearchKeyboardShortcuts(searchInputRef: Ref<any>) {
|
||||
const focusSearchInput = () => {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const blurSearchInput = () => {
|
||||
if (searchInputRef.value?.$el) {
|
||||
const inputElement = searchInputRef.value.$el.querySelector('input') || searchInputRef.value.$el
|
||||
if (inputElement && typeof inputElement.blur === 'function') {
|
||||
inputElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalKeydown = (event: KeyboardEvent) => {
|
||||
// ⌘K or Ctrl+K to focus search
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
||||
event.preventDefault()
|
||||
focusSearchInput()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchKeydown = (event: KeyboardEvent) => {
|
||||
// Escape key clears search or blurs input
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
return true // Signal to clear search
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
})
|
||||
|
||||
return {
|
||||
focusSearchInput,
|
||||
blurSearchInput,
|
||||
handleSearchKeydown
|
||||
}
|
||||
}
|
||||
430
src/modules/market/docs/category-filter-improvements.md
Normal file
430
src/modules/market/docs/category-filter-improvements.md
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
# Category Filter System - Future Improvements
|
||||
|
||||
This document outlines potential enhancements to the category filtering system based on user needs and advanced UX patterns.
|
||||
|
||||
## 🎯 Current Implementation Status
|
||||
|
||||
✅ **Completed:**
|
||||
- Reusable `useCategoryFilter` composable
|
||||
- Set-based performance optimizations
|
||||
- Full accessibility (ARIA, keyboard navigation, screen readers)
|
||||
- Theme-aware semantic styling
|
||||
- Proper Nostr event tag extraction (`'t'` tags)
|
||||
- Real-time reactive filtering
|
||||
|
||||
## 🚀 Proposed Future Improvements
|
||||
|
||||
### 1. **Advanced Filtering Logic**
|
||||
|
||||
#### ~~AND/OR Filter Modes~~
|
||||
✅ impemented!
|
||||
|
||||
Currently uses OR logic (show products with ANY selected category). Add support for AND logic.
|
||||
|
||||
```typescript
|
||||
interface AdvancedFilterOptions {
|
||||
mode: 'any' | 'all' // OR vs AND logic
|
||||
caseSensitive: boolean
|
||||
includeEmpty: boolean
|
||||
minCount: number
|
||||
maxSelections?: number // Limit concurrent selections
|
||||
}
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- More precise filtering for power users
|
||||
- Better product discovery in large catalogs
|
||||
|
||||
**Implementation:**
|
||||
```typescript
|
||||
// In useCategoryFilter.ts
|
||||
const filteredProducts = computed(() => {
|
||||
// ... existing code
|
||||
|
||||
return products.value.filter(product => {
|
||||
const matches = product.categories?.filter(cat =>
|
||||
selectedCategories.value.has(cat.toLowerCase())
|
||||
) || []
|
||||
|
||||
return options.mode === 'any'
|
||||
? matches.length > 0
|
||||
: matches.length === selectedCategories.value.size
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. **Hierarchical Categories**
|
||||
|
||||
Support nested category structures for better organization.
|
||||
|
||||
```typescript
|
||||
interface HierarchicalCategory {
|
||||
id: string
|
||||
name: string
|
||||
parent?: string
|
||||
children?: string[]
|
||||
level: number
|
||||
path: string[] // e.g., ['Electronics', 'Computers', 'Laptops']
|
||||
}
|
||||
```
|
||||
|
||||
**UI Enhancement:**
|
||||
- Expandable tree structure
|
||||
- Breadcrumb navigation
|
||||
- Parent/child selection logic
|
||||
|
||||
**Example:**
|
||||
```
|
||||
📁 Electronics (25)
|
||||
└── 💻 Computers (12)
|
||||
└── 💾 Storage (5)
|
||||
└── 📱 Mobile (13)
|
||||
📁 Clothing (18)
|
||||
└── 👕 Shirts (8)
|
||||
└── 👖 Pants (10)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. **Search Within Categories**
|
||||
|
||||
Add search functionality for large category lists.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="category-search mb-3">
|
||||
<Input
|
||||
v-model="categorySearchQuery"
|
||||
placeholder="Search categories..."
|
||||
class="text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="category-list max-h-64 overflow-y-auto">
|
||||
<div v-for="category in filteredCategories" ...>
|
||||
<!-- Highlight matching text -->
|
||||
<span v-html="highlightMatch(category.name, categorySearchQuery)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Fuzzy search within category names
|
||||
- Highlight matching text
|
||||
- Keyboard navigation through results
|
||||
|
||||
---
|
||||
|
||||
### 4. **Category Metadata & Visualization**
|
||||
|
||||
Enhance categories with rich metadata and visual cues.
|
||||
|
||||
```typescript
|
||||
interface EnhancedCategoryItem {
|
||||
category: string
|
||||
count: number
|
||||
selected: boolean
|
||||
metadata?: {
|
||||
color?: string // Brand color for visual consistency
|
||||
icon?: string // Lucide icon name or emoji
|
||||
description?: string // Tooltip description
|
||||
trending?: boolean // Popular/trending indicator
|
||||
new?: boolean // Recently added categories
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Visual Enhancements:**
|
||||
```vue
|
||||
<Badge :style="{ backgroundColor: category.metadata?.color }">
|
||||
<component :is="category.metadata?.icon" class="w-3 h-3 mr-1" />
|
||||
{{ category.category }}
|
||||
<TrendingUp v-if="category.metadata?.trending" class="w-3 h-3 ml-1" />
|
||||
</Badge>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. **Persistent Filter State**
|
||||
|
||||
Remember user preferences across sessions.
|
||||
|
||||
```typescript
|
||||
// composables/usePersistentCategoryFilter.ts
|
||||
export function usePersistentCategoryFilter() {
|
||||
const storageKey = 'market-category-filters'
|
||||
|
||||
const savedFilters = useLocalStorage(storageKey, {
|
||||
selectedCategories: [] as string[],
|
||||
filterMode: 'any' as 'any' | 'all',
|
||||
sortPreference: 'popularity' as 'popularity' | 'alphabetical'
|
||||
})
|
||||
|
||||
return {
|
||||
savedFilters,
|
||||
saveCurrentState,
|
||||
restoreState,
|
||||
clearSavedState
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Remember selected categories
|
||||
- Save filter preferences (AND/OR mode)
|
||||
- Cross-device sync (if user is authenticated)
|
||||
|
||||
---
|
||||
|
||||
### 6. **Smart Categories & Auto-suggestions**
|
||||
|
||||
AI-powered category suggestions and smart filtering.
|
||||
|
||||
```typescript
|
||||
interface SmartCategoryFeatures {
|
||||
suggestCategories: (searchQuery: string) => string[]
|
||||
relatedCategories: (selectedCategory: string) => string[]
|
||||
popularCombinations: () => string[][]
|
||||
seasonalRecommendations: () => string[]
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Ideas:**
|
||||
- "Users who selected X also selected Y"
|
||||
- Seasonal category promotion (winter → clothing, electronics)
|
||||
- Search query to category mapping
|
||||
|
||||
---
|
||||
|
||||
### 7. **Advanced UI Patterns**
|
||||
|
||||
#### Multi-Column Layout
|
||||
For markets with many categories:
|
||||
|
||||
```vue
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||
<CategoryColumn
|
||||
v-for="column in categorizedColumns"
|
||||
:categories="column"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Collapsible Groups
|
||||
Group categories by type with expand/collapse:
|
||||
|
||||
```vue
|
||||
<details v-for="group in categoryGroups" class="border rounded mb-2">
|
||||
<summary class="font-semibold p-2 cursor-pointer">
|
||||
{{ group.name }} ({{ group.totalCount }})
|
||||
</summary>
|
||||
<div class="p-2 pt-0">
|
||||
<CategoryBadge v-for="cat in group.categories" ... />
|
||||
</div>
|
||||
</details>
|
||||
```
|
||||
|
||||
#### Tag Cloud Visualization
|
||||
Show categories sized by popularity:
|
||||
|
||||
```vue
|
||||
<div class="tag-cloud">
|
||||
<button
|
||||
v-for="category in allCategories"
|
||||
:style="{ fontSize: getTagSize(category.count) }"
|
||||
class="tag-item"
|
||||
>
|
||||
{{ category.category }}
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. **Performance Optimizations**
|
||||
|
||||
#### Virtual Scrolling
|
||||
For markets with 100+ categories:
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { RecycleScroller } from 'vue-virtual-scroller'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RecycleScroller
|
||||
class="category-scroller"
|
||||
:items="allCategories"
|
||||
:item-size="40"
|
||||
key-field="category"
|
||||
v-slot="{ item }"
|
||||
>
|
||||
<CategoryBadge :category="item" />
|
||||
</RecycleScroller>
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Web Workers
|
||||
For heavy category processing:
|
||||
|
||||
```typescript
|
||||
// workers/categoryProcessor.ts
|
||||
self.onmessage = function(e) {
|
||||
const { products, options } = e.data
|
||||
const categoryMap = processCategoriesInWorker(products, options)
|
||||
self.postMessage(categoryMap)
|
||||
}
|
||||
|
||||
// In composable
|
||||
const categoryWorker = new Worker('/workers/categoryProcessor.js')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. **Analytics & Insights**
|
||||
|
||||
Track category usage for business intelligence:
|
||||
|
||||
```typescript
|
||||
interface CategoryAnalytics {
|
||||
trackCategorySelection: (category: string) => void
|
||||
trackFilterCombination: (categories: string[]) => void
|
||||
trackSearchPatterns: (query: string, results: number) => void
|
||||
generateInsights: () => {
|
||||
popularCategories: string[]
|
||||
unusedCategories: string[]
|
||||
conversionByCategory: Record<string, number>
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. **Mobile-First Enhancements**
|
||||
|
||||
#### Bottom Sheet Interface
|
||||
Mobile-optimized category selector:
|
||||
|
||||
```vue
|
||||
<Sheet v-model:open="showCategorySheet">
|
||||
<SheetContent side="bottom" class="h-[70vh]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Filter by Category</SheetTitle>
|
||||
</SheetHeader>
|
||||
<ScrollArea class="flex-1">
|
||||
<CategoryGrid :categories="allCategories" />
|
||||
</ScrollArea>
|
||||
<SheetFooter>
|
||||
<Button @click="applyFilters">Apply Filters</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
#### Swipe Gestures
|
||||
```vue
|
||||
<script setup>
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
|
||||
const { isSwiping, direction } = useSwipe(categoryContainer, {
|
||||
onSwipeEnd(e, direction) {
|
||||
if (direction === 'left') nextCategoryPage()
|
||||
if (direction === 'right') prevCategoryPage()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Implementation Priority
|
||||
|
||||
### **Phase 1: Essential UX** (2-3 days)
|
||||
1. ✅ AND/OR filter modes
|
||||
2. ✅ Persistent filter state
|
||||
3. ✅ Mobile bottom sheet interface
|
||||
|
||||
### **Phase 2: Advanced Features** (1-2 weeks)
|
||||
1. 🔄 Hierarchical categories
|
||||
2. 🔄 Category search functionality
|
||||
3. 🔄 Smart suggestions
|
||||
|
||||
### **Phase 3: Enterprise Features** (2-3 weeks)
|
||||
1. 🔄 Analytics & insights
|
||||
2. 🔄 Virtual scrolling
|
||||
3. 🔄 Web worker optimizations
|
||||
|
||||
### **Phase 4: Polish** (1 week)
|
||||
1. 🔄 Enhanced visualizations
|
||||
2. 🔄 Advanced animations
|
||||
3. 🔄 A11y improvements
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### **Unit Tests**
|
||||
```typescript
|
||||
// tests/useCategoryFilter.test.ts
|
||||
describe('useCategoryFilter', () => {
|
||||
test('should handle AND/OR filter modes', () => {
|
||||
// Test implementation
|
||||
})
|
||||
|
||||
test('should persist selected categories', () => {
|
||||
// Test localStorage integration
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### **E2E Tests**
|
||||
```typescript
|
||||
// e2e/category-filtering.spec.ts
|
||||
test('category filtering workflow', async ({ page }) => {
|
||||
await page.goto('/market')
|
||||
|
||||
// Test category selection
|
||||
await page.click('[data-testid="category-electronics"]')
|
||||
await expect(page.locator('[data-testid="product-grid"]')).toContainText('Electronics')
|
||||
|
||||
// Test filter persistence
|
||||
await page.reload()
|
||||
await expect(page.locator('[data-testid="category-electronics"]')).toHaveClass(/selected/)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Success Metrics
|
||||
|
||||
### **Performance Metrics**
|
||||
- Category rendering time < 100ms
|
||||
- Filter application time < 50ms
|
||||
- Memory usage < 10MB for 1000+ categories
|
||||
|
||||
### **UX Metrics**
|
||||
- Category selection rate > 60%
|
||||
- Filter abandonment rate < 10%
|
||||
- Mobile usability score > 95%
|
||||
|
||||
### **Business Metrics**
|
||||
- Product discovery improvement
|
||||
- Conversion rate by category
|
||||
- User engagement with filtering features
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- [Vue 3 Composition API Guide](https://vuejs.org/guide/extras/composition-api-faq.html)
|
||||
- [VueUse Composables](https://vueuse.org/)
|
||||
- [Accessibility Guidelines (WCAG 2.1)](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [Nostr NIP Standards](https://github.com/nostr-protocol/nips)
|
||||
|
||||
---
|
||||
|
||||
*Last updated: $(date +%Y-%m-%d)*
|
||||
*Next review: 2024-02-01*
|
||||
|
|
@ -145,6 +145,15 @@ export const marketModule: ModulePlugin = {
|
|||
title: 'Checkout',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/market/stall/:stallId',
|
||||
name: 'stall-view',
|
||||
component: () => import('./views/StallView.vue'),
|
||||
meta: {
|
||||
title: 'Stall',
|
||||
requiresAuth: false
|
||||
}
|
||||
}
|
||||
] as RouteRecordRaw[],
|
||||
|
||||
|
|
|
|||
|
|
@ -17,25 +17,11 @@ export interface Merchant {
|
|||
}
|
||||
}
|
||||
|
||||
export interface Stall {
|
||||
id: string
|
||||
wallet: string
|
||||
name: string
|
||||
currency: string
|
||||
shipping_zones: Array<{
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
countries: string[]
|
||||
}>
|
||||
config: {
|
||||
image_url?: string
|
||||
description?: string
|
||||
}
|
||||
pending: boolean
|
||||
event_id?: string
|
||||
event_created_at?: number
|
||||
}
|
||||
// Import StallApiResponse from types/market.ts
|
||||
import type { StallApiResponse } from '../types/market'
|
||||
|
||||
// Use StallApiResponse as the API response type
|
||||
export type Stall = StallApiResponse
|
||||
|
||||
export interface CreateMerchantRequest {
|
||||
config: {
|
||||
|
|
@ -68,7 +54,8 @@ export interface ProductConfig {
|
|||
shipping: ProductShippingCost[]
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
// API Response Types - Raw data from LNbits API
|
||||
export interface ProductApiResponse {
|
||||
id?: string
|
||||
stall_id: string
|
||||
name: string
|
||||
|
|
@ -358,8 +345,8 @@ export class NostrmarketAPI extends BaseService {
|
|||
/**
|
||||
* Get products for a stall
|
||||
*/
|
||||
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<Product[]> {
|
||||
const products = await this.request<Product[]>(
|
||||
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<ProductApiResponse[]> {
|
||||
const products = await this.request<ProductApiResponse[]>(
|
||||
`/api/v1/stall/product/${stallId}?pending=${pending}`,
|
||||
walletInkey,
|
||||
{ method: 'GET' }
|
||||
|
|
@ -380,8 +367,8 @@ export class NostrmarketAPI extends BaseService {
|
|||
async createProduct(
|
||||
walletAdminkey: string,
|
||||
productData: CreateProductRequest
|
||||
): Promise<Product> {
|
||||
const product = await this.request<Product>(
|
||||
): Promise<ProductApiResponse> {
|
||||
const product = await this.request<ProductApiResponse>(
|
||||
'/api/v1/product',
|
||||
walletAdminkey,
|
||||
{
|
||||
|
|
@ -405,9 +392,9 @@ export class NostrmarketAPI extends BaseService {
|
|||
async updateProduct(
|
||||
walletAdminkey: string,
|
||||
productId: string,
|
||||
productData: Product
|
||||
): Promise<Product> {
|
||||
const product = await this.request<Product>(
|
||||
productData: ProductApiResponse
|
||||
): Promise<ProductApiResponse> {
|
||||
const product = await this.request<ProductApiResponse>(
|
||||
`/api/v1/product/${productId}`,
|
||||
walletAdminkey,
|
||||
{
|
||||
|
|
@ -427,9 +414,9 @@ export class NostrmarketAPI extends BaseService {
|
|||
/**
|
||||
* Get a single product by ID
|
||||
*/
|
||||
async getProduct(walletInkey: string, productId: string): Promise<Product | null> {
|
||||
async getProduct(walletInkey: string, productId: string): Promise<ProductApiResponse | null> {
|
||||
try {
|
||||
const product = await this.request<Product>(
|
||||
const product = await this.request<ProductApiResponse>(
|
||||
`/api/v1/product/${productId}`,
|
||||
walletInkey,
|
||||
{ method: 'GET' }
|
||||
|
|
|
|||
|
|
@ -803,6 +803,10 @@ export const useMarketStore = defineStore('market', () => {
|
|||
filterData.value.categories.push(category)
|
||||
}
|
||||
}
|
||||
|
||||
const clearCategoryFilters = () => {
|
||||
filterData.value.categories = []
|
||||
}
|
||||
|
||||
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
|
||||
sortOptions.value = { field, order }
|
||||
|
|
@ -881,6 +885,7 @@ export const useMarketStore = defineStore('market', () => {
|
|||
updateFilterData,
|
||||
clearFilters,
|
||||
toggleCategoryFilter,
|
||||
clearCategoryFilters,
|
||||
updateSortOptions,
|
||||
formatPrice,
|
||||
addToStallCart,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface Market {
|
|||
}
|
||||
}
|
||||
|
||||
// Domain Model - Single source of truth for Stall
|
||||
export interface Stall {
|
||||
id: string
|
||||
pubkey: string
|
||||
|
|
@ -20,12 +21,12 @@ export interface Stall {
|
|||
description?: string
|
||||
logo?: string
|
||||
categories?: string[]
|
||||
shipping?: ShippingZone[]
|
||||
shipping_zones?: ShippingZone[] // LNbits format
|
||||
shipping: ShippingZone[]
|
||||
currency: string
|
||||
nostrEventId?: string
|
||||
}
|
||||
|
||||
// Domain Model - Single source of truth for Product
|
||||
export interface Product {
|
||||
id: string
|
||||
stall_id: string
|
||||
|
|
@ -40,6 +41,67 @@ export interface Product {
|
|||
createdAt: number
|
||||
updatedAt: number
|
||||
nostrEventId?: string
|
||||
// API-specific properties for merchant store management
|
||||
active?: boolean
|
||||
pending?: boolean
|
||||
config?: { currency?: string, [key: string]: any }
|
||||
}
|
||||
|
||||
// Type aliases for API responses - imported from services
|
||||
export type { ProductApiResponse } from '../services/nostrmarketAPI'
|
||||
|
||||
// Mapping function to convert API response to domain model
|
||||
export function mapApiResponseToProduct(
|
||||
apiProduct: import('../services/nostrmarketAPI').ProductApiResponse,
|
||||
stallName: string,
|
||||
stallCurrency: string = 'sats'
|
||||
): Product {
|
||||
return {
|
||||
id: apiProduct.id || `${apiProduct.stall_id}-${Date.now()}`,
|
||||
stall_id: apiProduct.stall_id,
|
||||
stallName,
|
||||
name: apiProduct.name,
|
||||
description: apiProduct.config?.description || '',
|
||||
price: apiProduct.price,
|
||||
currency: stallCurrency,
|
||||
quantity: apiProduct.quantity,
|
||||
images: apiProduct.images,
|
||||
categories: apiProduct.categories,
|
||||
createdAt: apiProduct.event_created_at || Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
nostrEventId: apiProduct.event_id,
|
||||
active: apiProduct.active,
|
||||
pending: apiProduct.pending,
|
||||
config: apiProduct.config
|
||||
}
|
||||
}
|
||||
|
||||
// Mapper function to convert API response to domain model
|
||||
export function mapApiResponseToStall(
|
||||
apiStall: StallApiResponse,
|
||||
pubkey: string = '',
|
||||
categories: string[] = []
|
||||
): Stall {
|
||||
return {
|
||||
id: apiStall.id,
|
||||
pubkey,
|
||||
name: apiStall.name,
|
||||
description: apiStall.config?.description,
|
||||
logo: apiStall.config?.image_url,
|
||||
categories,
|
||||
shipping: apiStall.shipping_zones?.map(zone => ({
|
||||
id: zone.id,
|
||||
name: zone.name,
|
||||
cost: zone.cost,
|
||||
currency: apiStall.currency,
|
||||
countries: zone.countries,
|
||||
description: `${zone.name} shipping`,
|
||||
estimatedDays: undefined,
|
||||
requiresPhysicalShipping: true
|
||||
})) || [],
|
||||
currency: apiStall.currency,
|
||||
nostrEventId: apiStall.event_id
|
||||
}
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
|
|
@ -103,6 +165,27 @@ export interface ShippingZone {
|
|||
requiresPhysicalShipping?: boolean
|
||||
}
|
||||
|
||||
// API Response Types - Raw data from LNbits backend
|
||||
export interface StallApiResponse {
|
||||
id: string
|
||||
wallet: string
|
||||
name: string
|
||||
currency: string
|
||||
shipping_zones: Array<{
|
||||
id: string
|
||||
name: string
|
||||
cost: number
|
||||
countries: string[]
|
||||
}>
|
||||
config: {
|
||||
image_url?: string
|
||||
description?: string
|
||||
}
|
||||
pending: boolean
|
||||
event_id?: string
|
||||
event_created_at?: number
|
||||
}
|
||||
|
||||
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
|
||||
|
||||
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
||||
|
|
|
|||
|
|
@ -54,13 +54,19 @@
|
|||
<div class="flex items-center space-x-4">
|
||||
<!-- Product Image -->
|
||||
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
|
||||
<img
|
||||
v-if="item.product.images?.[0]"
|
||||
:src="item.product.images[0]"
|
||||
<ProgressiveImage
|
||||
v-if="item.product.images?.[0]"
|
||||
:src="item.product.images[0]"
|
||||
:alt="item.product.name"
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
container-class="w-full h-full"
|
||||
image-class="w-full h-full object-cover rounded-lg"
|
||||
:blur-radius="4"
|
||||
:transition-duration="300"
|
||||
loading="lazy"
|
||||
/>
|
||||
<Package v-else class="w-8 h-8 text-muted-foreground" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<Package class="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
|
|
@ -284,9 +290,10 @@ import { Button } from '@/components/ui/button'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Package,
|
||||
CheckCircle
|
||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||
import {
|
||||
Package,
|
||||
CheckCircle
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -349,8 +356,8 @@ const orderTotal = computed(() => {
|
|||
const availableShippingZones = computed(() => {
|
||||
if (!currentStall.value) return []
|
||||
|
||||
// Check if stall has shipping_zones (LNbits format) or shipping (nostr-market-app format)
|
||||
const zones = currentStall.value.shipping_zones || currentStall.value.shipping || []
|
||||
// Use standardized shipping property from domain model
|
||||
const zones = currentStall.value.shipping || []
|
||||
|
||||
// Ensure zones have required properties and determine shipping requirements
|
||||
return zones.map(zone => {
|
||||
|
|
|
|||
|
|
@ -1,121 +1,146 @@
|
|||
<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>
|
||||
<LoadingErrorState
|
||||
:is-loading="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading.value)"
|
||||
:loading-message="marketPreloader.isPreloading.value ? 'Preloading market...' : 'Loading market...'"
|
||||
:has-error="!!(marketStore.error || marketPreloader.preloadError.value) && !isMarketReady"
|
||||
error-title="Failed to load market"
|
||||
:error-message="marketStore.error || marketPreloader.preloadError.value || ''"
|
||||
@retry="retryLoadMarket"
|
||||
>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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 -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
|
||||
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
|
||||
<AvatarFallback>M</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
|
||||
</h1>
|
||||
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
|
||||
{{ marketStore.activeMarket.opts.description }}
|
||||
</p>
|
||||
<!-- Enhanced Search Bar - Full Width on Mobile -->
|
||||
<div class="w-full lg:flex-1 lg:max-w-md">
|
||||
<MarketSearchBar
|
||||
:data="marketStore.products as Product[]"
|
||||
:options="searchOptions"
|
||||
:show-enhancements="true"
|
||||
@results="handleSearchResults"
|
||||
@filter-category="handleCategoryFilter"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="flex-1 max-w-md ml-8">
|
||||
<Input
|
||||
v-model="marketStore.searchText"
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category Filters -->
|
||||
<div v-if="marketStore.allCategories.length > 0" class="mb-6">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge
|
||||
v-for="category in marketStore.allCategories"
|
||||
:key="category.category"
|
||||
:variant="category.selected ? 'default' : 'secondary'"
|
||||
class="cursor-pointer hover:bg-blue-100"
|
||||
@click="marketStore.toggleCategoryFilter(category.category)"
|
||||
>
|
||||
{{ category.category }}
|
||||
<span class="ml-1 text-xs">({{ category.count }})</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- No Products State -->
|
||||
<div v-if="isMarketReady && marketStore.filteredProducts.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 && marketStore.filteredProducts.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 marketStore.filteredProducts"
|
||||
:key="product.id"
|
||||
:product="product"
|
||||
@add-to-cart="addToCart"
|
||||
@view-details="viewProduct"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
|
||||
<!-- 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 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-stall="viewStall"
|
||||
/>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<CartButton />
|
||||
|
||||
</LoadingErrorState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed } from 'vue'
|
||||
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 { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
import ProductCard from '../components/ProductCard.vue'
|
||||
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||
import ProductGrid from '../components/ProductGrid.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import CartButton from '../components/CartButton.vue'
|
||||
import LoadingErrorState from '../components/LoadingErrorState.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[]>([])
|
||||
|
||||
|
||||
// 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 &&
|
||||
|
|
@ -126,21 +151,74 @@ const needsToLoadMarket = computed(() => {
|
|||
// 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)
|
||||
|
||||
const hasError = !!(marketStore.error || marketPreloader.preloadError.value)
|
||||
|
||||
// Market is ready if:
|
||||
// 1. Successfully preloaded, OR
|
||||
// 2. Has products, not loading, and no current errors
|
||||
const ready = marketPreloader.isPreloaded.value ||
|
||||
(marketStore.products.length > 0 && !isLoading && !hasError)
|
||||
|
||||
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')
|
||||
}
|
||||
// TODO: Determine if we need naddr for market configuration
|
||||
// Currently bypassing naddr requirement as it may not be needed
|
||||
// Consider using naddr only for nostr-related configuration
|
||||
// Original code: const naddr = config.market.defaultNaddr
|
||||
// if (!naddr) throw new Error('No market naddr configured')
|
||||
|
||||
await market.connectToMarket()
|
||||
await market.loadMarket(naddr)
|
||||
|
||||
// Load market with naddr if available, otherwise load default market
|
||||
const naddr = config.market.defaultNaddr
|
||||
if (naddr) {
|
||||
await market.loadMarket(naddr)
|
||||
} else {
|
||||
// Load market without specific naddr - uses default market data
|
||||
await market.loadMarket('')
|
||||
}
|
||||
|
||||
// Subscribe to real-time updates
|
||||
unsubscribe = market.subscribeToMarketUpdates()
|
||||
|
|
@ -156,16 +234,24 @@ const retryLoadMarket = () => {
|
|||
loadMarket()
|
||||
}
|
||||
|
||||
const addToCart = (product: any) => {
|
||||
marketStore.addToCart(product)
|
||||
const addToCart = (product: Product, quantity?: number) => {
|
||||
marketStore.addToStallCart(product, quantity || 1)
|
||||
}
|
||||
|
||||
const viewProduct = (_product: any) => {
|
||||
// TODO: Navigate to product detail page
|
||||
|
||||
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(() => {
|
||||
|
|
|
|||
333
src/modules/market/views/StallView.vue
Normal file
333
src/modules/market/views/StallView.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<template>
|
||||
<div class="container mx-auto px-4 py-3 sm:py-6">
|
||||
<!-- Stall Header -->
|
||||
<div class="mb-4 sm:mb-8">
|
||||
<!-- Back to Market Button -->
|
||||
<Button
|
||||
@click="goBackToMarket"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="mb-2 sm:mb-4"
|
||||
>
|
||||
<ArrowLeft class="w-4 h-4 mr-2" />
|
||||
Back to Market
|
||||
</Button>
|
||||
|
||||
<!-- Compact Stall Info Card -->
|
||||
<Card class="relative overflow-hidden border-l-4 border-l-primary/60 bg-gradient-to-r from-primary/5 via-background to-accent/5 shadow-md">
|
||||
<div class="relative p-3 sm:p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Stall Logo (Enhanced) -->
|
||||
<div class="flex-shrink-0">
|
||||
<div v-if="stall?.logo" class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 border-2 border-primary/20 shadow-lg overflow-hidden ring-2 ring-primary/10">
|
||||
<img
|
||||
:src="stall.logo"
|
||||
:alt="stall.name"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@error="handleLogoError"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center shadow-lg ring-2 ring-primary/10 border-2 border-primary/20">
|
||||
<Store class="w-6 h-6 sm:w-7 sm:h-7 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stall Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Title and Description -->
|
||||
<div class="mb-2">
|
||||
<h1 class="text-lg sm:text-xl font-bold bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text text-transparent truncate">{{ stall?.name || 'Unknown Stall' }}</h1>
|
||||
<p v-if="stall?.description" class="text-sm text-muted-foreground/90 line-clamp-2 mt-1">
|
||||
{{ stall.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Stats Row -->
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<Badge class="text-xs font-medium bg-gradient-to-r from-primary to-primary/80 text-primary-foreground shadow-sm hover:shadow-md transition-shadow">
|
||||
<span class="font-bold">{{ productCount }}</span>
|
||||
<span class="ml-1 opacity-90">Products</span>
|
||||
</Badge>
|
||||
<Badge class="text-xs font-medium bg-gradient-to-r from-accent to-accent/80 text-accent-foreground shadow-sm">
|
||||
<span class="font-bold">{{ stall?.currency || 'sats' }}</span>
|
||||
<span class="ml-1 opacity-90">Currency</span>
|
||||
</Badge>
|
||||
<Badge v-if="stall?.shipping?.length" class="text-xs font-medium bg-gradient-to-r from-green-500 to-green-600 text-white shadow-sm">
|
||||
<span class="font-bold">{{ stall.shipping.length }}</span>
|
||||
<span class="ml-1 opacity-90">Shipping</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Category Filter Bar -->
|
||||
<CategoryFilterBar
|
||||
v-if="stallCategories.length > 0"
|
||||
:categories="stallCategories"
|
||||
:selected-count="selectedCategories.length"
|
||||
:filter-mode="filterMode"
|
||||
:product-count="productCount"
|
||||
title="Categories"
|
||||
@toggle-category="toggleCategoryFilter"
|
||||
@set-filter-mode="setFilterMode"
|
||||
@clear-filters="clearCategoryFilters"
|
||||
class="mb-4 sm:mb-6"
|
||||
/>
|
||||
|
||||
<!-- Search and Filter Bar -->
|
||||
<div class="mb-4 sm:mb-6 flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div class="flex-1">
|
||||
<MarketSearchBar
|
||||
:data="stallProducts as Product[]"
|
||||
:options="searchOptions"
|
||||
placeholder="Search products in this stall..."
|
||||
:show-enhancements="false"
|
||||
@results="handleSearchResults"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Select v-model="sortBy">
|
||||
<SelectTrigger class="w-[180px]">
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="price-asc">Price: Low to High</SelectItem>
|
||||
<SelectItem value="price-desc">Price: High to Low</SelectItem>
|
||||
<SelectItem value="newest">Newest First</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
v-if="selectedCategories.length > 0"
|
||||
@click="clearFilters"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Clear Filters
|
||||
<X class="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</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-stall="viewStall"
|
||||
@add-to-cart="handleAddToCart"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<CartButton />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { ArrowLeft, Store, X } from 'lucide-vue-next'
|
||||
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||
import ProductGrid from '../components/ProductGrid.vue'
|
||||
import CartButton from '../components/CartButton.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import type { Product, Stall } from '../types/market'
|
||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
// State
|
||||
const isLoading = ref(false)
|
||||
const searchResults = ref<Product[]>([])
|
||||
const searchQuery = ref('')
|
||||
const sortBy = ref('name')
|
||||
const selectedCategories = ref<string[]>([])
|
||||
const filterMode = ref<'any' | 'all'>('any')
|
||||
const logoError = ref(false)
|
||||
|
||||
// Fuzzy search configuration for stall products
|
||||
const searchOptions: FuzzySearchOptions<Product> = {
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.8 }, // Product name has highest weight in stall view
|
||||
{ name: 'description', weight: 0.4 }, // Description is important for specific product search
|
||||
{ name: 'categories', weight: 0.3 } // Categories for filtering within stall
|
||||
],
|
||||
threshold: 0.2, // More strict matching since we're within a single stall
|
||||
ignoreLocation: true,
|
||||
findAllMatches: true,
|
||||
minMatchCharLength: 2,
|
||||
shouldSort: true
|
||||
},
|
||||
resultLimit: 100, // Less restrictive limit for stall view
|
||||
minSearchLength: 2,
|
||||
matchAllWhenSearchEmpty: true
|
||||
}
|
||||
|
||||
// Get stall ID from route params
|
||||
const stallId = computed(() => route.params.stallId as string)
|
||||
|
||||
// Get stall data
|
||||
const stall = computed(() => {
|
||||
return marketStore.stalls.find(s => s.id === stallId.value) as Stall | undefined
|
||||
})
|
||||
|
||||
// Get products for this stall
|
||||
const stallProducts = computed(() => {
|
||||
return marketStore.products.filter(p => p.stall_id === stallId.value)
|
||||
})
|
||||
|
||||
// Get unique categories for this stall
|
||||
const stallCategories = computed(() => {
|
||||
const categoryCount = new Map<string, number>()
|
||||
stallProducts.value.forEach(product => {
|
||||
product.categories?.forEach(cat => {
|
||||
categoryCount.set(cat, (categoryCount.get(cat) || 0) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(categoryCount.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([category, count]) => ({
|
||||
category,
|
||||
count,
|
||||
selected: selectedCategories.value.includes(category)
|
||||
}))
|
||||
})
|
||||
|
||||
// Product count
|
||||
const productCount = computed(() => stallProducts.value.length)
|
||||
|
||||
// Filtered and sorted products (using fuzzy search results when available)
|
||||
const filteredProducts = computed(() => {
|
||||
// Use search results if available, otherwise use all stall products
|
||||
let products = searchResults.value.length > 0 || searchResults.value.length === 0
|
||||
? [...searchResults.value]
|
||||
: [...stallProducts.value]
|
||||
|
||||
// Filter by selected categories
|
||||
if (selectedCategories.value.length > 0) {
|
||||
products = products.filter(p =>
|
||||
p.categories?.some(cat => selectedCategories.value.includes(cat))
|
||||
)
|
||||
}
|
||||
|
||||
// Sort products
|
||||
switch (sortBy.value) {
|
||||
case 'price-asc':
|
||||
products.sort((a, b) => a.price - b.price)
|
||||
break
|
||||
case 'price-desc':
|
||||
products.sort((a, b) => b.price - a.price)
|
||||
break
|
||||
case 'newest':
|
||||
products.sort((a, b) => b.createdAt - a.createdAt)
|
||||
break
|
||||
case 'name':
|
||||
default:
|
||||
products.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
return products
|
||||
})
|
||||
|
||||
// Methods
|
||||
const goBackToMarket = () => {
|
||||
router.push('/market')
|
||||
}
|
||||
|
||||
const toggleCategoryFilter = (category: string) => {
|
||||
const index = selectedCategories.value.indexOf(category)
|
||||
if (index >= 0) {
|
||||
selectedCategories.value.splice(index, 1)
|
||||
} else {
|
||||
selectedCategories.value.push(category)
|
||||
}
|
||||
}
|
||||
|
||||
const setFilterMode = (mode: 'any' | 'all') => {
|
||||
filterMode.value = mode
|
||||
}
|
||||
|
||||
const clearCategoryFilters = () => {
|
||||
selectedCategories.value = []
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
selectedCategories.value = []
|
||||
searchResults.value = []
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Handle fuzzy search results
|
||||
const handleSearchResults = (results: Product[]) => {
|
||||
searchResults.value = results
|
||||
// Extract search query from fuzzy search component if needed
|
||||
// For now, we'll track it separately
|
||||
}
|
||||
|
||||
const handleAddToCart = (product: Product, quantity?: number) => {
|
||||
marketStore.addToStallCart(product, quantity || 1)
|
||||
}
|
||||
|
||||
|
||||
const viewStall = (otherStallId: string) => {
|
||||
if (otherStallId !== stallId.value) {
|
||||
router.push(`/market/stall/${otherStallId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogoError = () => {
|
||||
logoError.value = true
|
||||
}
|
||||
|
||||
// Load stall data if needed
|
||||
onMounted(async () => {
|
||||
if (!stall.value) {
|
||||
isLoading.value = true
|
||||
// You might want to load the stall data here if it's not already loaded
|
||||
// For now, we'll assume it's already in the store
|
||||
setTimeout(() => {
|
||||
isLoading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for route changes to update the view
|
||||
watch(() => route.params.stallId, (newStallId) => {
|
||||
if (newStallId && newStallId !== stallId.value) {
|
||||
// Reset filters when navigating to a different stall
|
||||
clearFilters()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any custom styles here if needed */
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue