Merge branch 'marketplace'

This commit is contained in:
padreug 2025-09-28 03:42:46 +02:00
commit e062dfe2b8
26 changed files with 3651 additions and 253 deletions

View 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>

View 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>

View file

@ -8,6 +8,7 @@
:src="item.product.images?.[0] || '/placeholder-product.png'"
:alt="item.product.name"
class="w-16 h-16 object-cover rounded-md"
loading="lazy"
@error="handleImageError"
/>
</div>
@ -106,6 +107,7 @@
:src="item.product.images?.[0] || '/placeholder-product.png'"
:alt="item.product.name"
class="w-16 h-16 object-cover rounded-md"
loading="lazy"
@error="handleImageError"
/>
</div>

View file

@ -20,6 +20,7 @@
:src="item.product.images?.[0] || '/placeholder-product.png'"
:alt="item.product.name"
class="w-8 h-8 object-cover rounded"
loading="lazy"
/>
<div>
<p class="font-medium text-foreground">{{ item.product.name }}</p>

View 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>

View 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>

View file

@ -89,8 +89,9 @@
<FormControl>
<div class="flex items-center space-x-2">
<Checkbox
:checked="value"
@update:checked="handleChange"
:key="`active-checkbox-${props.isOpen}`"
:model-value="value"
@update:model-value="handleChange"
:disabled="isCreating"
/>
<Label>Product is active and visible</Label>
@ -104,14 +105,20 @@
</div>
<!-- Categories -->
<FormField name="categories">
<FormField v-slot="{ value, handleChange }" name="categories">
<FormItem>
<FormLabel>Categories</FormLabel>
<FormDescription>Add categories to help customers find your product</FormDescription>
<div class="text-center py-8 border-2 border-dashed rounded-lg">
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">Category management coming soon</p>
</div>
<FormControl>
<CategoryInput
:model-value="value || []"
@update:model-value="handleChange"
:disabled="isCreating"
placeholder="Enter category (e.g., electronics, clothing, books...)"
:max-categories="10"
:show-popular-categories="true"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
@ -136,8 +143,8 @@
<div class="flex items-center space-x-2">
<FormControl>
<Checkbox
:checked="value"
@update:checked="handleChange"
:model-value="value"
@update:model-value="handleChange"
:disabled="isCreating"
/>
</FormControl>
@ -202,6 +209,7 @@ import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import CategoryInput from './CategoryInput.vue'
import {
FormControl,
FormDescription,
@ -211,7 +219,8 @@ import {
FormMessage,
} from '@/components/ui/form'
import { Package } from 'lucide-vue-next'
import type { NostrmarketAPI, Stall, Product, CreateProductRequest } from '../services/nostrmarketAPI'
import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
@ -318,9 +327,9 @@ const updateProduct = async (formData: any) => {
createError.value = null
try {
const productData: Product = {
id: props.product.id,
stall_id: props.product.stall_id,
const productData = {
id: props.product?.id,
stall_id: props.product?.stall_id || props.stall?.id || '',
name,
categories: categories || [],
images: images || [],
@ -330,11 +339,13 @@ const updateProduct = async (formData: any) => {
pending: false,
config: {
description: description || '',
currency: props.stall?.currency || props.product.config.currency,
currency: props.stall?.currency || props.product?.config?.currency || 'sats',
use_autoreply,
autoreply_message: use_autoreply ? autoreply_message || '' : '',
shipping: props.product.config.shipping || []
}
shipping: props.product?.config?.shipping || []
},
event_id: props.product?.nostrEventId,
event_created_at: props.product?.createdAt
}
const adminKey = paymentService.getPreferredWalletAdminKey()
@ -455,13 +466,13 @@ watch(() => props.isOpen, async (isOpen) => {
use_autoreply: false,
autoreply_message: ''
}
// Reset form with appropriate initial values
resetForm({ values: initialValues })
// Wait for reactivity
await nextTick()
// Clear any previous errors
createError.value = null
}

View 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>

View 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>

View 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>

View file

@ -1,56 +1,56 @@
<template>
<div class="space-y-6">
<!-- Loading State -->
<div v-if="isLoadingMerchant" class="flex flex-col items-center justify-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-muted/50 rounded-full flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<div v-if="isLoadingMerchant" class="flex justify-center items-center py-12">
<div class="flex flex-col items-center space-y-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p class="text-gray-600">Loading your merchant profile...</p>
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Checking Merchant Status</h3>
<p class="text-muted-foreground">Loading your merchant profile...</p>
</div>
<!-- Error State -->
<div v-else-if="merchantCheckError" class="flex flex-col items-center justify-center py-12">
<div class="w-16 h-16 mx-auto mb-4 bg-red-500/10 rounded-full flex items-center justify-center">
<AlertCircle class="w-8 h-8 text-red-500" />
<div v-else-if="merchantCheckError" class="flex justify-center items-center py-12">
<div class="text-center">
<h2 class="text-2xl font-bold text-red-600 mb-4">Error Loading Merchant Status</h2>
<p class="text-gray-600 mb-4">{{ merchantCheckError }}</p>
<Button @click="checkMerchantProfile" variant="outline">
Try Again
</Button>
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Error Loading Merchant Status</h3>
<p class="text-muted-foreground mb-4">{{ merchantCheckError }}</p>
<Button @click="checkMerchantProfile" variant="outline">
Try Again
</Button>
</div>
<!-- No Merchant Profile Empty State -->
<div v-else-if="!userHasMerchantProfile" class="flex flex-col items-center justify-center py-12">
<div class="w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6">
<User class="w-12 h-12 text-muted-foreground" />
</div>
<h2 class="text-2xl font-bold text-foreground mb-2">Create Your Merchant Profile</h2>
<p class="text-muted-foreground text-center mb-6 max-w-md">
Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
</p>
<Button
@click="createMerchantProfile"
variant="default"
size="lg"
:disabled="isCreatingMerchant"
>
<div v-if="isCreatingMerchant" class="flex items-center">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Creating...</span>
</div>
<div v-else class="flex items-center">
<Plus class="w-5 h-5 mr-2" />
<span>Create Merchant Profile</span>
</div>
</Button>
</div>
<!-- Stores Grid (shown when merchant profile exists) -->
<!-- Content -->
<div v-else>
<!-- Header Section -->
<div class="mb-8">
<!-- No Merchant Profile Empty State -->
<div v-if="!userHasMerchantProfile" class="flex flex-col items-center justify-center py-12">
<div class="w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-6">
<User class="w-12 h-12 text-muted-foreground" />
</div>
<h2 class="text-2xl font-bold text-foreground mb-2">Create Your Merchant Profile</h2>
<p class="text-muted-foreground text-center mb-6 max-w-md">
Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
</p>
<Button
@click="createMerchantProfile"
variant="default"
size="lg"
:disabled="isCreatingMerchant"
>
<div v-if="isCreatingMerchant" class="flex items-center">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
<span>Creating...</span>
</div>
<div v-else class="flex items-center">
<Plus class="w-5 h-5 mr-2" />
<span>Create Merchant Profile</span>
</div>
</Button>
</div>
<!-- Stores Grid (shown when merchant profile exists) -->
<div v-else>
<!-- Header Section -->
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<div>
<h2 class="text-2xl font-bold text-foreground">My Stores</h2>
@ -258,7 +258,7 @@
<div class="space-y-3">
<div>
<h4 class="font-semibold text-foreground">{{ product.name }}</h4>
<p v-if="product.config.description" class="text-sm text-muted-foreground mt-1">
<p v-if="product.config?.description" class="text-sm text-muted-foreground mt-1">
{{ product.config.description }}
</p>
</div>
@ -266,7 +266,7 @@
<div class="flex items-center justify-between">
<div>
<span class="text-lg font-bold text-foreground">
{{ product.price }} {{ product.config.currency || activeStall?.currency || 'sat' }}
{{ product.price }} {{ product.config?.currency || activeStall?.currency || 'sat' }}
</span>
<div class="text-sm text-muted-foreground">
Qty: {{ product.quantity }}
@ -309,22 +309,23 @@
</div>
</div>
<!-- Create Store Dialog -->
<CreateStoreDialog
:is-open="showCreateStoreDialog"
@close="showCreateStoreDialog = false"
@created="onStoreCreated"
/>
<!-- Create Store Dialog -->
<CreateStoreDialog
:is-open="showCreateStoreDialog"
@close="showCreateStoreDialog = false"
@created="onStoreCreated"
/>
<!-- Create Product Dialog -->
<CreateProductDialog
:is-open="showCreateProductDialog"
:stall="activeStall"
:product="editingProduct"
@close="closeProductDialog"
@created="onProductCreated"
@updated="onProductUpdated"
/>
<!-- Create Product Dialog -->
<CreateProductDialog
:is-open="showCreateProductDialog"
:stall="activeStall"
:product="editingProduct"
@close="closeProductDialog"
@created="onProductCreated"
@updated="onProductUpdated"
/>
</div>
</template>
<script setup lang="ts">
@ -333,16 +334,17 @@ import { useRouter } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Package,
Store,
DollarSign,
Star,
Plus,
AlertCircle,
import {
Package,
Store,
DollarSign,
Star,
Plus,
User
} from 'lucide-vue-next'
import type { NostrmarketAPI, Merchant, Stall, Product } from '../services/nostrmarketAPI'
import type { NostrmarketAPI, Merchant, Stall } from '../services/nostrmarketAPI'
import type { Product } from '../types/market'
import { mapApiResponseToProduct } from '../types/market'
import { auth } from '@/composables/useAuthService'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
@ -516,7 +518,22 @@ const loadStallProducts = async () => {
inkey,
activeStall.value.id!
)
stallProducts.value = products || []
// Convert API responses to domain models using clean mapping function
const enrichedProducts = (products || []).map(product =>
mapApiResponseToProduct(
product,
activeStall.value?.name || 'Unknown Stall',
activeStall.value?.currency || 'sats'
)
)
stallProducts.value = enrichedProducts
// Only add active products to the market store so they appear in the main market
enrichedProducts
.filter(product => product.active)
.forEach(product => {
marketStore.addProduct(product)
})
} catch (error) {
console.error('Failed to load products:', error)
stallProducts.value = []
@ -623,4 +640,4 @@ watch(() => activeStallId.value, async (newStallId) => {
stallProducts.value = []
}
})
</script>
</script>

View file

@ -2,12 +2,27 @@
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
<!-- Product Image -->
<div class="relative">
<img
:src="product.images?.[0] || '/placeholder-product.png'"
<!-- Show actual image if available -->
<ProgressiveImage
v-if="product.images?.[0]"
:src="product.images[0]"
:alt="product.name"
class="w-full h-48 object-cover"
container-class="w-full h-48 bg-muted/50"
image-class="w-full h-48 object-cover"
:blur-radius="8"
:transition-duration="400"
loading="lazy"
:show-loading-indicator="false"
@error="handleImageError"
/>
<!-- Show placeholder when no image -->
<div v-else class="w-full h-48 bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
<div class="text-center">
<Package class="w-12 h-12 mx-auto text-muted-foreground mb-2" />
<span class="text-xs text-muted-foreground">No image available</span>
</div>
</div>
<!-- Add to Cart Button -->
<Button
@ -101,11 +116,11 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useMarketStore } from '@/modules/market/stores/market'
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { ShoppingCart } from 'lucide-vue-next'
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import { ShoppingCart, Package } from 'lucide-vue-next'
import type { Product } from '@/modules/market/stores/market'
interface Props {
@ -114,16 +129,16 @@ interface Props {
const props = defineProps<Props>()
// const emit = defineEmits<{
// 'view-details': [product: Product]
// 'view-stall': [stallId: string]
// }>()
const emit = defineEmits<{
'add-to-cart': [product: Product]
'view-details': [product: Product]
'view-stall': [stallId: string]
}>()
const marketStore = useMarketStore()
const imageError = ref(false)
const addToCart = () => {
marketStore.addToStallCart(props.product, 1)
emit('add-to-cart', props.product)
}
const handleImageError = () => {

View 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>

View 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>

View 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>

View 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

View file

@ -286,24 +286,37 @@ export function useMarket() {
productGroups.forEach((productEvents, productId) => {
// Sort by created_at and take the most recent
const latestEvent = productEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0]
try {
const productData = JSON.parse(latestEvent.content)
const stallId = productData.stall_id || 'unknown'
// Extract categories from Nostr event tags (standard approach)
const categories = latestEvent.tags
.filter((tag: any) => tag[0] === 't')
.map((tag: any) => tag[1])
.filter((cat: string) => cat && cat.trim())
// Look up the stall name from the stalls array
const stall = marketStore.stalls.find(s => s.id === stallId)
const stallName = stall?.name || 'Unknown Stall'
const product = {
id: productId,
stall_id: productData.stall_id || 'unknown',
stallName: productData.stallName || 'Unknown Stall',
stall_id: stallId,
stallName: stallName,
name: productData.name || 'Unnamed Product',
description: productData.description || '',
price: productData.price || 0,
currency: productData.currency || 'sats',
quantity: productData.quantity || 1,
images: productData.images || [],
categories: productData.categories || [],
categories: categories,
createdAt: latestEvent.created_at,
updatedAt: latestEvent.created_at
}
marketStore.addProduct(product)
} catch (err) {
// Silently handle parse errors
@ -468,10 +481,22 @@ export function useMarket() {
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
if (productId) {
const productData = JSON.parse(event.content)
const stallId = productData.stall_id || 'unknown'
// Extract categories from Nostr event tags (standard approach)
const categories = event.tags
.filter((tag: any) => tag[0] === 't')
.map((tag: any) => tag[1])
.filter((cat: string) => cat && cat.trim())
// Look up the stall name from the stalls array
const stall = marketStore.stalls.find(s => s.id === stallId)
const stallName = stall?.name || 'Unknown Stall'
const product = {
id: productId,
stall_id: productData.stall_id || 'unknown',
stallName: productData.stallName || 'Unknown Stall',
stall_id: stallId,
stallName: stallName,
pubkey: event.pubkey,
name: productData.name || 'Unnamed Product',
description: productData.description || '',
@ -479,11 +504,11 @@ export function useMarket() {
currency: productData.currency || 'sats',
quantity: productData.quantity || 1,
images: productData.images || [],
categories: productData.categories || [],
categories: categories,
createdAt: event.created_at,
updatedAt: event.created_at
}
marketStore.addProduct(product)
}
} catch (err) {

View 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
}
}

View 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*

View file

@ -145,6 +145,15 @@ export const marketModule: ModulePlugin = {
title: 'Checkout',
requiresAuth: false
}
},
{
path: '/market/stall/:stallId',
name: 'stall-view',
component: () => import('./views/StallView.vue'),
meta: {
title: 'Stall',
requiresAuth: false
}
}
] as RouteRecordRaw[],

View file

@ -17,25 +17,11 @@ export interface Merchant {
}
}
export interface Stall {
id: string
wallet: string
name: string
currency: string
shipping_zones: Array<{
id: string
name: string
cost: number
countries: string[]
}>
config: {
image_url?: string
description?: string
}
pending: boolean
event_id?: string
event_created_at?: number
}
// Import StallApiResponse from types/market.ts
import type { StallApiResponse } from '../types/market'
// Use StallApiResponse as the API response type
export type Stall = StallApiResponse
export interface CreateMerchantRequest {
config: {
@ -68,7 +54,8 @@ export interface ProductConfig {
shipping: ProductShippingCost[]
}
export interface Product {
// API Response Types - Raw data from LNbits API
export interface ProductApiResponse {
id?: string
stall_id: string
name: string
@ -358,8 +345,8 @@ export class NostrmarketAPI extends BaseService {
/**
* Get products for a stall
*/
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<Product[]> {
const products = await this.request<Product[]>(
async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise<ProductApiResponse[]> {
const products = await this.request<ProductApiResponse[]>(
`/api/v1/stall/product/${stallId}?pending=${pending}`,
walletInkey,
{ method: 'GET' }
@ -380,8 +367,8 @@ export class NostrmarketAPI extends BaseService {
async createProduct(
walletAdminkey: string,
productData: CreateProductRequest
): Promise<Product> {
const product = await this.request<Product>(
): Promise<ProductApiResponse> {
const product = await this.request<ProductApiResponse>(
'/api/v1/product',
walletAdminkey,
{
@ -405,9 +392,9 @@ export class NostrmarketAPI extends BaseService {
async updateProduct(
walletAdminkey: string,
productId: string,
productData: Product
): Promise<Product> {
const product = await this.request<Product>(
productData: ProductApiResponse
): Promise<ProductApiResponse> {
const product = await this.request<ProductApiResponse>(
`/api/v1/product/${productId}`,
walletAdminkey,
{
@ -427,9 +414,9 @@ export class NostrmarketAPI extends BaseService {
/**
* Get a single product by ID
*/
async getProduct(walletInkey: string, productId: string): Promise<Product | null> {
async getProduct(walletInkey: string, productId: string): Promise<ProductApiResponse | null> {
try {
const product = await this.request<Product>(
const product = await this.request<ProductApiResponse>(
`/api/v1/product/${productId}`,
walletInkey,
{ method: 'GET' }

View file

@ -803,6 +803,10 @@ export const useMarketStore = defineStore('market', () => {
filterData.value.categories.push(category)
}
}
const clearCategoryFilters = () => {
filterData.value.categories = []
}
const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => {
sortOptions.value = { field, order }
@ -881,6 +885,7 @@ export const useMarketStore = defineStore('market', () => {
updateFilterData,
clearFilters,
toggleCategoryFilter,
clearCategoryFilters,
updateSortOptions,
formatPrice,
addToStallCart,

View file

@ -13,6 +13,7 @@ export interface Market {
}
}
// Domain Model - Single source of truth for Stall
export interface Stall {
id: string
pubkey: string
@ -20,12 +21,12 @@ export interface Stall {
description?: string
logo?: string
categories?: string[]
shipping?: ShippingZone[]
shipping_zones?: ShippingZone[] // LNbits format
shipping: ShippingZone[]
currency: string
nostrEventId?: string
}
// Domain Model - Single source of truth for Product
export interface Product {
id: string
stall_id: string
@ -40,6 +41,67 @@ export interface Product {
createdAt: number
updatedAt: number
nostrEventId?: string
// API-specific properties for merchant store management
active?: boolean
pending?: boolean
config?: { currency?: string, [key: string]: any }
}
// Type aliases for API responses - imported from services
export type { ProductApiResponse } from '../services/nostrmarketAPI'
// Mapping function to convert API response to domain model
export function mapApiResponseToProduct(
apiProduct: import('../services/nostrmarketAPI').ProductApiResponse,
stallName: string,
stallCurrency: string = 'sats'
): Product {
return {
id: apiProduct.id || `${apiProduct.stall_id}-${Date.now()}`,
stall_id: apiProduct.stall_id,
stallName,
name: apiProduct.name,
description: apiProduct.config?.description || '',
price: apiProduct.price,
currency: stallCurrency,
quantity: apiProduct.quantity,
images: apiProduct.images,
categories: apiProduct.categories,
createdAt: apiProduct.event_created_at || Date.now(),
updatedAt: Date.now(),
nostrEventId: apiProduct.event_id,
active: apiProduct.active,
pending: apiProduct.pending,
config: apiProduct.config
}
}
// Mapper function to convert API response to domain model
export function mapApiResponseToStall(
apiStall: StallApiResponse,
pubkey: string = '',
categories: string[] = []
): Stall {
return {
id: apiStall.id,
pubkey,
name: apiStall.name,
description: apiStall.config?.description,
logo: apiStall.config?.image_url,
categories,
shipping: apiStall.shipping_zones?.map(zone => ({
id: zone.id,
name: zone.name,
cost: zone.cost,
currency: apiStall.currency,
countries: zone.countries,
description: `${zone.name} shipping`,
estimatedDays: undefined,
requiresPhysicalShipping: true
})) || [],
currency: apiStall.currency,
nostrEventId: apiStall.event_id
}
}
export interface Order {
@ -103,6 +165,27 @@ export interface ShippingZone {
requiresPhysicalShipping?: boolean
}
// API Response Types - Raw data from LNbits backend
export interface StallApiResponse {
id: string
wallet: string
name: string
currency: string
shipping_zones: Array<{
id: string
name: string
cost: number
countries: string[]
}>
config: {
image_url?: string
description?: string
}
pending: boolean
event_id?: string
event_created_at?: number
}
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing'
export type PaymentMethod = 'lightning' | 'btc_onchain'

View file

@ -54,13 +54,19 @@
<div class="flex items-center space-x-4">
<!-- Product Image -->
<div class="w-16 h-16 bg-muted rounded-lg flex items-center justify-center">
<img
v-if="item.product.images?.[0]"
:src="item.product.images[0]"
<ProgressiveImage
v-if="item.product.images?.[0]"
:src="item.product.images[0]"
:alt="item.product.name"
class="w-full h-full object-cover rounded-lg"
container-class="w-full h-full"
image-class="w-full h-full object-cover rounded-lg"
:blur-radius="4"
:transition-duration="300"
loading="lazy"
/>
<Package v-else class="w-8 h-8 text-muted-foreground" />
<div v-else class="w-full h-full flex items-center justify-center">
<Package class="w-8 h-8 text-muted-foreground" />
</div>
</div>
<!-- Product Details -->
@ -284,9 +290,10 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Package,
CheckCircle
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import {
Package,
CheckCircle
} from 'lucide-vue-next'
const route = useRoute()
@ -349,8 +356,8 @@ const orderTotal = computed(() => {
const availableShippingZones = computed(() => {
if (!currentStall.value) return []
// Check if stall has shipping_zones (LNbits format) or shipping (nostr-market-app format)
const zones = currentStall.value.shipping_zones || currentStall.value.shipping || []
// Use standardized shipping property from domain model
const zones = currentStall.value.shipping || []
// Ensure zones have required properties and determine shipping requirements
return zones.map(zone => {

View file

@ -1,121 +1,146 @@
<template>
<div class="container mx-auto px-4 py-8">
<!-- Loading State -->
<div v-if="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading)" class="flex justify-center items-center min-h-64">
<div class="flex flex-col items-center space-y-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<p class="text-gray-600">
{{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
</p>
</div>
</div>
<LoadingErrorState
:is-loading="!isMarketReady && ((marketStore.isLoading ?? false) || marketPreloader.isPreloading.value)"
:loading-message="marketPreloader.isPreloading.value ? 'Preloading market...' : 'Loading market...'"
:has-error="!!(marketStore.error || marketPreloader.preloadError.value) && !isMarketReady"
error-title="Failed to load market"
:error-message="marketStore.error || marketPreloader.preloadError.value || ''"
@retry="retryLoadMarket"
>
<!-- Market Header - Optimized for Mobile -->
<div class="mb-4 sm:mb-6 lg:mb-8">
<!-- Market Info and Search - Responsive Layout -->
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3 sm:gap-4 lg:gap-6">
<!-- Market Info - Compact on Mobile -->
<div class="flex items-center space-x-3 sm:space-x-4">
<Avatar v-if="marketStore.activeMarket?.opts?.logo" class="h-10 w-10 sm:h-12 sm:w-12">
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
<AvatarFallback>M</AvatarFallback>
</Avatar>
<div>
<h1 class="text-xl sm:text-2xl lg:text-3xl font-bold">
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
</h1>
<p v-if="marketStore.activeMarket?.opts?.description" class="text-muted-foreground text-xs sm:text-sm lg:text-base line-clamp-1 sm:line-clamp-2">
{{ marketStore.activeMarket.opts.description }}
</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="(marketStore.error || marketPreloader.preloadError) && marketStore.products.length === 0" class="flex justify-center items-center min-h-64">
<div class="text-center">
<h2 class="text-2xl font-bold text-red-600 mb-4">Failed to load market</h2>
<p class="text-gray-600 mb-4">{{ marketStore.error || marketPreloader.preloadError }}</p>
<Button @click="retryLoadMarket" variant="outline">
Try Again
</Button>
</div>
</div>
<!-- Market Content -->
<div v-else>
<!-- Market Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center space-x-4">
<Avatar v-if="marketStore.activeMarket?.opts?.logo">
<AvatarImage :src="marketStore.activeMarket.opts.logo" />
<AvatarFallback>M</AvatarFallback>
</Avatar>
<div>
<h1 class="text-3xl font-bold">
{{ marketStore.activeMarket?.opts?.name || 'Market' }}
</h1>
<p v-if="marketStore.activeMarket?.opts?.description" class="text-gray-600">
{{ marketStore.activeMarket.opts.description }}
</p>
<!-- Enhanced Search Bar - Full Width on Mobile -->
<div class="w-full lg:flex-1 lg:max-w-md">
<MarketSearchBar
:data="marketStore.products as Product[]"
:options="searchOptions"
:show-enhancements="true"
@results="handleSearchResults"
@filter-category="handleCategoryFilter"
class="w-full"
/>
</div>
</div>
<!-- Search Bar -->
<div class="flex-1 max-w-md ml-8">
<Input
v-model="marketStore.searchText"
type="text"
placeholder="Search products..."
class="w-full"
/>
</div>
</div>
<!-- Category Filters -->
<div v-if="marketStore.allCategories.length > 0" class="mb-6">
<div class="flex flex-wrap gap-2">
<Badge
v-for="category in marketStore.allCategories"
:key="category.category"
:variant="category.selected ? 'default' : 'secondary'"
class="cursor-pointer hover:bg-blue-100"
@click="marketStore.toggleCategoryFilter(category.category)"
>
{{ category.category }}
<span class="ml-1 text-xs">({{ category.count }})</span>
</Badge>
</div>
</div>
<!-- No Products State -->
<div v-if="isMarketReady && marketStore.filteredProducts.length === 0 && !(marketStore.isLoading ?? false)" class="text-center py-12">
<h3 class="text-xl font-semibold text-gray-600 mb-2">No products found</h3>
<p class="text-gray-500">Try adjusting your search or filters</p>
</div>
<!-- Product Grid -->
<div v-if="isMarketReady && marketStore.filteredProducts.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="product in marketStore.filteredProducts"
:key="product.id"
:product="product"
@add-to-cart="addToCart"
@view-details="viewProduct"
/>
</div>
<!-- Enhanced Category Filters -->
<CategoryFilterBar
:categories="allCategories"
:selected-count="selectedCategoriesCount"
:filter-mode="filterMode"
:product-count="productsToDisplay.length"
@toggle-category="toggleCategory"
@clear-all="clearAllCategoryFilters"
@set-filter-mode="setFilterMode"
/>
<!-- Cart Summary -->
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4">
<Button @click="viewCart" class="shadow-lg">
<ShoppingCart class="w-5 h-5 mr-2" />
Cart ({{ marketStore.totalCartItems }})
</Button>
</div>
</div>
<!-- Product Grid with Loading and Empty States -->
<ProductGrid
v-if="isMarketReady"
:products="productsToDisplay as Product[]"
:is-loading="marketStore.isLoading ?? false"
loading-message="Loading products..."
empty-title="No products found"
empty-message="Try adjusting your search or filters"
@add-to-cart="addToCart"
@view-stall="viewStall"
/>
<!-- Cart Summary -->
<CartButton />
</LoadingErrorState>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, computed } from 'vue'
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market'
import { useMarket } from '../composables/useMarket'
import { useMarketPreloader } from '../composables/useMarketPreloader'
import { useCategoryFilter } from '../composables/useCategoryFilter'
import { config } from '@/lib/config'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { ShoppingCart } from 'lucide-vue-next'
import ProductCard from '../components/ProductCard.vue'
import MarketSearchBar from '../components/MarketSearchBar.vue'
import ProductGrid from '../components/ProductGrid.vue'
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
import CartButton from '../components/CartButton.vue'
import LoadingErrorState from '../components/LoadingErrorState.vue'
import type { Product } from '../types/market'
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
const router = useRouter()
const marketStore = useMarketStore()
const market = useMarket()
const marketPreloader = useMarketPreloader()
// Dynamic category filtering: use search results when available, otherwise all products
const productsForCategoryFilter = computed(() => {
return searchResults.value.length > 0
? searchResults.value
: (marketStore.products as Product[])
})
// Category filtering with optimized composable
const {
allCategories,
selectedCount: selectedCategoriesCount,
selectedCategoryNames: selectedCategories,
hasActiveFilters,
filterMode,
toggleCategory,
clearAllCategories: clearAllCategoryFilters,
setFilterMode
} = useCategoryFilter(productsForCategoryFilter)
let unsubscribe: (() => void) | null = null
// Fuzzy search state
const searchResults = ref<Product[]>([])
// Fuzzy search configuration for products and stalls
const searchOptions: FuzzySearchOptions<Product> = {
fuseOptions: {
keys: [
{ name: 'name', weight: 0.7 }, // Product name has highest weight
{ name: 'stallName', weight: 0.5 }, // Stall name is important for discovery
{ name: 'description', weight: 0.3 }, // Description provides context
{ name: 'categories', weight: 0.4 } // Categories help with discovery
],
threshold: 0.4, // More tolerant of typos (0.0 = perfect match, 1.0 = match anything)
ignoreLocation: true, // Don't care about where in the string the match is
findAllMatches: true, // Find all matches, not just the first
minMatchCharLength: 2, // Minimum length of a matched character sequence
shouldSort: true // Sort results by score
},
resultLimit: 50, // Limit results for performance
minSearchLength: 2, // Start searching after 2 characters
matchAllWhenSearchEmpty: true
}
// Check if we need to load market data
const needsToLoadMarket = computed(() => {
return !marketPreloader.isPreloaded.value &&
@ -126,21 +151,74 @@ const needsToLoadMarket = computed(() => {
// Check if market data is ready (either preloaded or loaded)
const isMarketReady = computed(() => {
const isLoading = marketStore.isLoading ?? false
const ready = marketPreloader.isPreloaded.value ||
(marketStore.products.length > 0 && !isLoading)
const hasError = !!(marketStore.error || marketPreloader.preloadError.value)
// Market is ready if:
// 1. Successfully preloaded, OR
// 2. Has products, not loading, and no current errors
const ready = marketPreloader.isPreloaded.value ||
(marketStore.products.length > 0 && !isLoading && !hasError)
return ready
})
// Products to display (combines search results with category filtering)
const productsToDisplay = computed(() => {
// Start with either search results or all products
const baseProducts = searchResults.value.length > 0
? searchResults.value
: marketStore.products
// Apply category filtering using our composable's proper AND/OR logic
if (!hasActiveFilters.value) {
return baseProducts
}
// Use the composable's filtering logic which supports AND/OR modes
return baseProducts.filter(product => {
if (!product.categories?.length) return false
// Normalize product categories
const productCategories = product.categories
.filter(cat => cat && cat.trim())
.map(cat => cat.toLowerCase())
if (productCategories.length === 0) return false
// Count matches between product categories and selected categories
const matchingCategories = productCategories.filter(cat =>
selectedCategories.value.includes(cat)
)
// Apply AND/OR logic based on filter mode
if (filterMode.value === 'all') {
// AND logic: Product must have ALL selected categories
return matchingCategories.length === selectedCategories.value.length
} else {
// OR logic: Product must have ANY selected category
return matchingCategories.length > 0
}
})
})
const loadMarket = async () => {
try {
const naddr = config.market.defaultNaddr
if (!naddr) {
throw new Error('No market naddr configured')
}
// TODO: Determine if we need naddr for market configuration
// Currently bypassing naddr requirement as it may not be needed
// Consider using naddr only for nostr-related configuration
// Original code: const naddr = config.market.defaultNaddr
// if (!naddr) throw new Error('No market naddr configured')
await market.connectToMarket()
await market.loadMarket(naddr)
// Load market with naddr if available, otherwise load default market
const naddr = config.market.defaultNaddr
if (naddr) {
await market.loadMarket(naddr)
} else {
// Load market without specific naddr - uses default market data
await market.loadMarket('')
}
// Subscribe to real-time updates
unsubscribe = market.subscribeToMarketUpdates()
@ -156,16 +234,24 @@ const retryLoadMarket = () => {
loadMarket()
}
const addToCart = (product: any) => {
marketStore.addToCart(product)
const addToCart = (product: Product, quantity?: number) => {
marketStore.addToStallCart(product, quantity || 1)
}
const viewProduct = (_product: any) => {
// TODO: Navigate to product detail page
const viewStall = (stallId: string) => {
// Navigate to the stall view page
router.push(`/market/stall/${stallId}`)
}
const viewCart = () => {
router.push('/cart')
// Handle fuzzy search results
const handleSearchResults = (results: Product[]) => {
searchResults.value = results
}
// Handle category filtering from fuzzy search
const handleCategoryFilter = (category: string) => {
toggleCategory(category)
}
onMounted(() => {

View 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>