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'"
|
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
class="w-16 h-16 object-cover rounded-md"
|
class="w-16 h-16 object-cover rounded-md"
|
||||||
|
loading="lazy"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,6 +107,7 @@
|
||||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
class="w-16 h-16 object-cover rounded-md"
|
class="w-16 h-16 object-cover rounded-md"
|
||||||
|
loading="lazy"
|
||||||
@error="handleImageError"
|
@error="handleImageError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
:src="item.product.images?.[0] || '/placeholder-product.png'"
|
||||||
:alt="item.product.name"
|
:alt="item.product.name"
|
||||||
class="w-8 h-8 object-cover rounded"
|
class="w-8 h-8 object-cover rounded"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-foreground">{{ item.product.name }}</p>
|
<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>
|
<FormControl>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="value"
|
:key="`active-checkbox-${props.isOpen}`"
|
||||||
@update:checked="handleChange"
|
:model-value="value"
|
||||||
|
@update:model-value="handleChange"
|
||||||
:disabled="isCreating"
|
:disabled="isCreating"
|
||||||
/>
|
/>
|
||||||
<Label>Product is active and visible</Label>
|
<Label>Product is active and visible</Label>
|
||||||
|
|
@ -104,14 +105,20 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Categories -->
|
<!-- Categories -->
|
||||||
<FormField name="categories">
|
<FormField v-slot="{ value, handleChange }" name="categories">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Categories</FormLabel>
|
<FormLabel>Categories</FormLabel>
|
||||||
<FormDescription>Add categories to help customers find your product</FormDescription>
|
<FormDescription>Add categories to help customers find your product</FormDescription>
|
||||||
<div class="text-center py-8 border-2 border-dashed rounded-lg">
|
<FormControl>
|
||||||
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
|
<CategoryInput
|
||||||
<p class="text-sm text-muted-foreground">Category management coming soon</p>
|
:model-value="value || []"
|
||||||
</div>
|
@update:model-value="handleChange"
|
||||||
|
:disabled="isCreating"
|
||||||
|
placeholder="Enter category (e.g., electronics, clothing, books...)"
|
||||||
|
:max-categories="10"
|
||||||
|
:show-popular-categories="true"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -136,8 +143,8 @@
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="value"
|
:model-value="value"
|
||||||
@update:checked="handleChange"
|
@update:model-value="handleChange"
|
||||||
:disabled="isCreating"
|
:disabled="isCreating"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
@ -202,6 +209,7 @@ import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import CategoryInput from './CategoryInput.vue'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormDescription,
|
FormDescription,
|
||||||
|
|
@ -211,7 +219,8 @@ import {
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Package } from 'lucide-vue-next'
|
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 { auth } from '@/composables/useAuthService'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
|
@ -318,9 +327,9 @@ const updateProduct = async (formData: any) => {
|
||||||
createError.value = null
|
createError.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const productData: Product = {
|
const productData = {
|
||||||
id: props.product.id,
|
id: props.product?.id,
|
||||||
stall_id: props.product.stall_id,
|
stall_id: props.product?.stall_id || props.stall?.id || '',
|
||||||
name,
|
name,
|
||||||
categories: categories || [],
|
categories: categories || [],
|
||||||
images: images || [],
|
images: images || [],
|
||||||
|
|
@ -330,11 +339,13 @@ const updateProduct = async (formData: any) => {
|
||||||
pending: false,
|
pending: false,
|
||||||
config: {
|
config: {
|
||||||
description: description || '',
|
description: description || '',
|
||||||
currency: props.stall?.currency || props.product.config.currency,
|
currency: props.stall?.currency || props.product?.config?.currency || 'sats',
|
||||||
use_autoreply,
|
use_autoreply,
|
||||||
autoreply_message: use_autoreply ? autoreply_message || '' : '',
|
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()
|
const adminKey = paymentService.getPreferredWalletAdminKey()
|
||||||
|
|
|
||||||
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>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Loading State -->
|
<!-- Loading State -->
|
||||||
<div v-if="isLoadingMerchant" class="flex flex-col items-center justify-center py-12">
|
<div v-if="isLoadingMerchant" class="flex justify-center items-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="flex flex-col items-center space-y-4">
|
||||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
<div v-else-if="merchantCheckError" class="flex flex-col items-center justify-center py-12">
|
<div v-else-if="merchantCheckError" class="flex justify-center items-center py-12">
|
||||||
<div class="w-16 h-16 mx-auto mb-4 bg-red-500/10 rounded-full flex items-center justify-center">
|
<div class="text-center">
|
||||||
<AlertCircle class="w-8 h-8 text-red-500" />
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- No Merchant Profile Empty State -->
|
<!-- Content -->
|
||||||
<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) -->
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<!-- Header Section -->
|
<!-- No Merchant Profile Empty State -->
|
||||||
<div class="mb-8">
|
<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 class="flex items-center justify-between mb-2">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-foreground">My Stores</h2>
|
<h2 class="text-2xl font-bold text-foreground">My Stores</h2>
|
||||||
|
|
@ -258,7 +258,7 @@
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-semibold text-foreground">{{ product.name }}</h4>
|
<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 }}
|
{{ product.config.description }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -266,7 +266,7 @@
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-lg font-bold text-foreground">
|
<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>
|
</span>
|
||||||
<div class="text-sm text-muted-foreground">
|
<div class="text-sm text-muted-foreground">
|
||||||
Qty: {{ product.quantity }}
|
Qty: {{ product.quantity }}
|
||||||
|
|
@ -309,22 +309,23 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Store Dialog -->
|
<!-- Create Store Dialog -->
|
||||||
<CreateStoreDialog
|
<CreateStoreDialog
|
||||||
:is-open="showCreateStoreDialog"
|
:is-open="showCreateStoreDialog"
|
||||||
@close="showCreateStoreDialog = false"
|
@close="showCreateStoreDialog = false"
|
||||||
@created="onStoreCreated"
|
@created="onStoreCreated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Create Product Dialog -->
|
<!-- Create Product Dialog -->
|
||||||
<CreateProductDialog
|
<CreateProductDialog
|
||||||
:is-open="showCreateProductDialog"
|
:is-open="showCreateProductDialog"
|
||||||
:stall="activeStall"
|
:stall="activeStall"
|
||||||
:product="editingProduct"
|
:product="editingProduct"
|
||||||
@close="closeProductDialog"
|
@close="closeProductDialog"
|
||||||
@created="onProductCreated"
|
@created="onProductCreated"
|
||||||
@updated="onProductUpdated"
|
@updated="onProductUpdated"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
@ -339,10 +340,11 @@ import {
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Star,
|
Star,
|
||||||
Plus,
|
Plus,
|
||||||
AlertCircle,
|
|
||||||
User
|
User
|
||||||
} from 'lucide-vue-next'
|
} 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 { auth } from '@/composables/useAuthService'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
|
|
@ -516,7 +518,22 @@ const loadStallProducts = async () => {
|
||||||
inkey,
|
inkey,
|
||||||
activeStall.value.id!
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to load products:', error)
|
console.error('Failed to load products:', error)
|
||||||
stallProducts.value = []
|
stallProducts.value = []
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,28 @@
|
||||||
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
||||||
<!-- Product Image -->
|
<!-- Product Image -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<!-- Show actual image if available -->
|
||||||
:src="product.images?.[0] || '/placeholder-product.png'"
|
<ProgressiveImage
|
||||||
|
v-if="product.images?.[0]"
|
||||||
|
:src="product.images[0]"
|
||||||
:alt="product.name"
|
: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"
|
@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 -->
|
<!-- Add to Cart Button -->
|
||||||
<Button
|
<Button
|
||||||
@click="addToCart"
|
@click="addToCart"
|
||||||
|
|
@ -101,11 +116,11 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useMarketStore } from '@/modules/market/stores/market'
|
|
||||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { 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'
|
import type { Product } from '@/modules/market/stores/market'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -114,16 +129,16 @@ interface Props {
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
// const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
// 'view-details': [product: Product]
|
'add-to-cart': [product: Product]
|
||||||
// 'view-stall': [stallId: string]
|
'view-details': [product: Product]
|
||||||
// }>()
|
'view-stall': [stallId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
const marketStore = useMarketStore()
|
|
||||||
const imageError = ref(false)
|
const imageError = ref(false)
|
||||||
|
|
||||||
const addToCart = () => {
|
const addToCart = () => {
|
||||||
marketStore.addToStallCart(props.product, 1)
|
emit('add-to-cart', props.product)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleImageError = () => {
|
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
|
||||||
|
|
@ -289,17 +289,30 @@ export function useMarket() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const productData = JSON.parse(latestEvent.content)
|
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 = {
|
const product = {
|
||||||
id: productId,
|
id: productId,
|
||||||
stall_id: productData.stall_id || 'unknown',
|
stall_id: stallId,
|
||||||
stallName: productData.stallName || 'Unknown Stall',
|
stallName: stallName,
|
||||||
name: productData.name || 'Unnamed Product',
|
name: productData.name || 'Unnamed Product',
|
||||||
description: productData.description || '',
|
description: productData.description || '',
|
||||||
price: productData.price || 0,
|
price: productData.price || 0,
|
||||||
currency: productData.currency || 'sats',
|
currency: productData.currency || 'sats',
|
||||||
quantity: productData.quantity || 1,
|
quantity: productData.quantity || 1,
|
||||||
images: productData.images || [],
|
images: productData.images || [],
|
||||||
categories: productData.categories || [],
|
categories: categories,
|
||||||
createdAt: latestEvent.created_at,
|
createdAt: latestEvent.created_at,
|
||||||
updatedAt: latestEvent.created_at
|
updatedAt: latestEvent.created_at
|
||||||
}
|
}
|
||||||
|
|
@ -468,10 +481,22 @@ export function useMarket() {
|
||||||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||||
if (productId) {
|
if (productId) {
|
||||||
const productData = JSON.parse(event.content)
|
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 = {
|
const product = {
|
||||||
id: productId,
|
id: productId,
|
||||||
stall_id: productData.stall_id || 'unknown',
|
stall_id: stallId,
|
||||||
stallName: productData.stallName || 'Unknown Stall',
|
stallName: stallName,
|
||||||
pubkey: event.pubkey,
|
pubkey: event.pubkey,
|
||||||
name: productData.name || 'Unnamed Product',
|
name: productData.name || 'Unnamed Product',
|
||||||
description: productData.description || '',
|
description: productData.description || '',
|
||||||
|
|
@ -479,7 +504,7 @@ export function useMarket() {
|
||||||
currency: productData.currency || 'sats',
|
currency: productData.currency || 'sats',
|
||||||
quantity: productData.quantity || 1,
|
quantity: productData.quantity || 1,
|
||||||
images: productData.images || [],
|
images: productData.images || [],
|
||||||
categories: productData.categories || [],
|
categories: categories,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
updatedAt: event.created_at
|
updatedAt: event.created_at
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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',
|
title: 'Checkout',
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/market/stall/:stallId',
|
||||||
|
name: 'stall-view',
|
||||||
|
component: () => import('./views/StallView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Stall',
|
||||||
|
requiresAuth: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
] as RouteRecordRaw[],
|
] as RouteRecordRaw[],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,25 +17,11 @@ export interface Merchant {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Stall {
|
// Import StallApiResponse from types/market.ts
|
||||||
id: string
|
import type { StallApiResponse } from '../types/market'
|
||||||
wallet: string
|
|
||||||
name: string
|
// Use StallApiResponse as the API response type
|
||||||
currency: string
|
export type Stall = StallApiResponse
|
||||||
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 interface CreateMerchantRequest {
|
export interface CreateMerchantRequest {
|
||||||
config: {
|
config: {
|
||||||
|
|
@ -68,7 +54,8 @@ export interface ProductConfig {
|
||||||
shipping: ProductShippingCost[]
|
shipping: ProductShippingCost[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
// API Response Types - Raw data from LNbits API
|
||||||
|
export interface ProductApiResponse {
|
||||||
id?: string
|
id?: string
|
||||||
stall_id: string
|
stall_id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -358,8 +345,8 @@ export class NostrmarketAPI extends BaseService {
|
||||||
/**
|
/**
|
||||||
* Get products for a stall
|
* Get products for a stall
|
||||||
*/
|
*/
|
||||||
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<Product[]> {
|
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<ProductApiResponse[]> {
|
||||||
const products = await this.request<Product[]>(
|
const products = await this.request<ProductApiResponse[]>(
|
||||||
`/api/v1/stall/product/${stallId}?pending=${pending}`,
|
`/api/v1/stall/product/${stallId}?pending=${pending}`,
|
||||||
walletInkey,
|
walletInkey,
|
||||||
{ method: 'GET' }
|
{ method: 'GET' }
|
||||||
|
|
@ -380,8 +367,8 @@ export class NostrmarketAPI extends BaseService {
|
||||||
async createProduct(
|
async createProduct(
|
||||||
walletAdminkey: string,
|
walletAdminkey: string,
|
||||||
productData: CreateProductRequest
|
productData: CreateProductRequest
|
||||||
): Promise<Product> {
|
): Promise<ProductApiResponse> {
|
||||||
const product = await this.request<Product>(
|
const product = await this.request<ProductApiResponse>(
|
||||||
'/api/v1/product',
|
'/api/v1/product',
|
||||||
walletAdminkey,
|
walletAdminkey,
|
||||||
{
|
{
|
||||||
|
|
@ -405,9 +392,9 @@ export class NostrmarketAPI extends BaseService {
|
||||||
async updateProduct(
|
async updateProduct(
|
||||||
walletAdminkey: string,
|
walletAdminkey: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
productData: Product
|
productData: ProductApiResponse
|
||||||
): Promise<Product> {
|
): Promise<ProductApiResponse> {
|
||||||
const product = await this.request<Product>(
|
const product = await this.request<ProductApiResponse>(
|
||||||
`/api/v1/product/${productId}`,
|
`/api/v1/product/${productId}`,
|
||||||
walletAdminkey,
|
walletAdminkey,
|
||||||
{
|
{
|
||||||
|
|
@ -427,9 +414,9 @@ export class NostrmarketAPI extends BaseService {
|
||||||
/**
|
/**
|
||||||
* Get a single product by ID
|
* 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 {
|
try {
|
||||||
const product = await this.request<Product>(
|
const product = await this.request<ProductApiResponse>(
|
||||||
`/api/v1/product/${productId}`,
|
`/api/v1/product/${productId}`,
|
||||||
walletInkey,
|
walletInkey,
|
||||||
{ method: 'GET' }
|
{ method: 'GET' }
|
||||||
|
|
|
||||||
|
|
@ -804,6 +804,10 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clearCategoryFilters = () => {
|
||||||
|
filterData.value.categories = []
|
||||||
|
}
|
||||||
|
|
||||||
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
|
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
|
||||||
sortOptions.value = { field, order }
|
sortOptions.value = { field, order }
|
||||||
}
|
}
|
||||||
|
|
@ -881,6 +885,7 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
updateFilterData,
|
updateFilterData,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
toggleCategoryFilter,
|
toggleCategoryFilter,
|
||||||
|
clearCategoryFilters,
|
||||||
updateSortOptions,
|
updateSortOptions,
|
||||||
formatPrice,
|
formatPrice,
|
||||||
addToStallCart,
|
addToStallCart,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export interface Market {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain Model - Single source of truth for Stall
|
||||||
export interface Stall {
|
export interface Stall {
|
||||||
id: string
|
id: string
|
||||||
pubkey: string
|
pubkey: string
|
||||||
|
|
@ -20,12 +21,12 @@ export interface Stall {
|
||||||
description?: string
|
description?: string
|
||||||
logo?: string
|
logo?: string
|
||||||
categories?: string[]
|
categories?: string[]
|
||||||
shipping?: ShippingZone[]
|
shipping: ShippingZone[]
|
||||||
shipping_zones?: ShippingZone[] // LNbits format
|
|
||||||
currency: string
|
currency: string
|
||||||
nostrEventId?: string
|
nostrEventId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Domain Model - Single source of truth for Product
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: string
|
id: string
|
||||||
stall_id: string
|
stall_id: string
|
||||||
|
|
@ -40,6 +41,67 @@ export interface Product {
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
nostrEventId?: string
|
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 {
|
export interface Order {
|
||||||
|
|
@ -103,6 +165,27 @@ export interface ShippingZone {
|
||||||
requiresPhysicalShipping?: boolean
|
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 OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
|
||||||
|
|
||||||
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
export type PaymentMethod = 'lightning' | 'btc_onchain'
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,19 @@
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<!-- Product Image -->
|
<!-- Product Image -->
|
||||||
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
|
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
|
||||||
<img
|
<ProgressiveImage
|
||||||
v-if="item.product.images?.[0]"
|
v-if="item.product.images?.[0]"
|
||||||
:src="item.product.images[0]"
|
:src="item.product.images[0]"
|
||||||
:alt="item.product.name"
|
: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>
|
</div>
|
||||||
|
|
||||||
<!-- Product Details -->
|
<!-- Product Details -->
|
||||||
|
|
@ -284,6 +290,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
CheckCircle
|
CheckCircle
|
||||||
|
|
@ -349,8 +356,8 @@ const orderTotal = computed(() => {
|
||||||
const availableShippingZones = computed(() => {
|
const availableShippingZones = computed(() => {
|
||||||
if (!currentStall.value) return []
|
if (!currentStall.value) return []
|
||||||
|
|
||||||
// Check if stall has shipping_zones (LNbits format) or shipping (nostr-market-app format)
|
// Use standardized shipping property from domain model
|
||||||
const zones = currentStall.value.shipping_zones || currentStall.value.shipping || []
|
const zones = currentStall.value.shipping || []
|
||||||
|
|
||||||
// Ensure zones have required properties and determine shipping requirements
|
// Ensure zones have required properties and determine shipping requirements
|
||||||
return zones.map(zone => {
|
return zones.map(zone => {
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,146 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<!-- Loading State -->
|
<LoadingErrorState
|
||||||
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
|
:is-loading="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading.value)"
|
||||||
<div class="flex flex-col items-center space-y-4">
|
:loading-message="marketPreloader.isPreloading.value ? 'Preloading market...' : 'Loading market...'"
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
:has-error="!!(marketStore.error || marketPreloader.preloadError.value) && !isMarketReady"
|
||||||
<p class="text-gray-600">
|
error-title="Failed to load market"
|
||||||
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
|
:error-message="marketStore.error || marketPreloader.preloadError.value || ''"
|
||||||
</p>
|
@retry="retryLoadMarket"
|
||||||
</div>
|
>
|
||||||
</div>
|
<!-- 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 -->
|
<!-- Enhanced Search Bar - Full Width on Mobile -->
|
||||||
<div v-else-if="(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0" class="flex justify-center items-center min-h-64">
|
<div class="w-full lg:flex-1 lg:max-w-md">
|
||||||
<div class="text-center">
|
<MarketSearchBar
|
||||||
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
|
:data="marketStore.products as Product[]"
|
||||||
<p class="text-gray-600 mb-4">{{ marketStore.error || marketPreloader.preloadError }}</p>
|
:options="searchOptions"
|
||||||
<Button @click="retryLoadMarket" variant="outline">
|
:show-enhancements="true"
|
||||||
Try Again
|
@results="handleSearchResults"
|
||||||
</Button>
|
@filter-category="handleCategoryFilter"
|
||||||
</div>
|
class="w-full"
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 -->
|
<!-- Enhanced Category Filters -->
|
||||||
<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">
|
<CategoryFilterBar
|
||||||
<ProductCard
|
:categories="allCategories"
|
||||||
v-for="product in marketStore.filteredProducts"
|
:selected-count="selectedCategoriesCount"
|
||||||
:key="product.id"
|
:filter-mode="filterMode"
|
||||||
:product="product"
|
:product-count="productsToDisplay.length"
|
||||||
@add-to-cart="addToCart"
|
@toggle-category="toggleCategory"
|
||||||
@view-details="viewProduct"
|
@clear-all="clearAllCategoryFilters"
|
||||||
/>
|
@set-filter-mode="setFilterMode"
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<!-- Cart Summary -->
|
<!-- Product Grid with Loading and Empty States -->
|
||||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
|
<ProductGrid
|
||||||
<Button @click="viewCart" class="shadow-lg">
|
v-if="isMarketReady"
|
||||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
:products="productsToDisplay as Product[]"
|
||||||
Cart ({{ marketStore.totalCartItems }})
|
:is-loading="marketStore.isLoading ?? false"
|
||||||
</Button>
|
loading-message="Loading products..."
|
||||||
</div>
|
empty-title="No products found"
|
||||||
</div>
|
empty-message="Try adjusting your search or filters"
|
||||||
|
@add-to-cart="addToCart"
|
||||||
|
@view-stall="viewStall"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cart Summary -->
|
||||||
|
<CartButton />
|
||||||
|
|
||||||
|
</LoadingErrorState>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, computed } from 'vue'
|
import { onMounted, onUnmounted, computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMarketStore } from '@/modules/market/stores/market'
|
import { useMarketStore } from '@/modules/market/stores/market'
|
||||||
import { useMarket } from '../composables/useMarket'
|
import { useMarket } from '../composables/useMarket'
|
||||||
import { useMarketPreloader } from '../composables/useMarketPreloader'
|
import { useMarketPreloader } from '../composables/useMarketPreloader'
|
||||||
|
import { useCategoryFilter } from '../composables/useCategoryFilter'
|
||||||
import { config } from '@/lib/config'
|
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 { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||||
import { ShoppingCart } from 'lucide-vue-next'
|
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||||
import ProductCard from '../components/ProductCard.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 router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
const market = useMarket()
|
const market = useMarket()
|
||||||
const marketPreloader = useMarketPreloader()
|
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
|
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
|
// Check if we need to load market data
|
||||||
const needsToLoadMarket = computed(() => {
|
const needsToLoadMarket = computed(() => {
|
||||||
return !marketPreloader.isPreloaded.value &&
|
return !marketPreloader.isPreloaded.value &&
|
||||||
|
|
@ -126,21 +151,74 @@ const needsToLoadMarket = computed(() => {
|
||||||
// Check if market data is ready (either preloaded or loaded)
|
// Check if market data is ready (either preloaded or loaded)
|
||||||
const isMarketReady = computed(() => {
|
const isMarketReady = computed(() => {
|
||||||
const isLoading = marketStore.isLoading ?? false
|
const isLoading = marketStore.isLoading ?? false
|
||||||
|
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 ||
|
const ready = marketPreloader.isPreloaded.value ||
|
||||||
(marketStore.products.length > 0 && !isLoading)
|
(marketStore.products.length > 0 && !isLoading && !hasError)
|
||||||
|
|
||||||
return ready
|
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 () => {
|
const loadMarket = async () => {
|
||||||
try {
|
try {
|
||||||
const naddr = config.market.defaultNaddr
|
// TODO: Determine if we need naddr for market configuration
|
||||||
if (!naddr) {
|
// Currently bypassing naddr requirement as it may not be needed
|
||||||
throw new Error('No market naddr configured')
|
// 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.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
|
// Subscribe to real-time updates
|
||||||
unsubscribe = market.subscribeToMarketUpdates()
|
unsubscribe = market.subscribeToMarketUpdates()
|
||||||
|
|
@ -156,16 +234,24 @@ const retryLoadMarket = () => {
|
||||||
loadMarket()
|
loadMarket()
|
||||||
}
|
}
|
||||||
|
|
||||||
const addToCart = (product: any) => {
|
const addToCart = (product: Product, quantity?: number) => {
|
||||||
marketStore.addToCart(product)
|
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 = () => {
|
// Handle fuzzy search results
|
||||||
router.push('/cart')
|
const handleSearchResults = (results: Product[]) => {
|
||||||
|
searchResults.value = results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle category filtering from fuzzy search
|
||||||
|
const handleCategoryFilter = (category: string) => {
|
||||||
|
toggleCategory(category)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
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