@@ -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
}
diff --git a/src/modules/market/components/LoadingErrorState.vue b/src/modules/market/components/LoadingErrorState.vue
new file mode 100644
index 0000000..06d160a
--- /dev/null
+++ b/src/modules/market/components/LoadingErrorState.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+ {{ loadingMessage || 'Loading...' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ errorTitle || 'Error' }}
+
+
+ {{ errorMessage || 'Something went wrong' }}
+
+
+
+
+ {{ retryLabel || 'Try Again' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/market/components/MarketFuzzySearch.vue b/src/modules/market/components/MarketFuzzySearch.vue
new file mode 100644
index 0000000..fb91960
--- /dev/null
+++ b/src/modules/market/components/MarketFuzzySearch.vue
@@ -0,0 +1,313 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⌘ K
+
+
+
+
+
+ Clear search
+
+
+
+
+
+
+
{{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found
+
+
+
+ In:
+
+ {{ category }}
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/market/components/MarketSearchBar.vue b/src/modules/market/components/MarketSearchBar.vue
new file mode 100644
index 0000000..870160d
--- /dev/null
+++ b/src/modules/market/components/MarketSearchBar.vue
@@ -0,0 +1,390 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⌘ K
+
+
+
+
+
+ Clear search
+
+
+
+
+
+
+
+
+
{{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found
+
+
+
+ In:
+
+ {{ category }}
+
+
+
+
+
+
+
+
+
+
+ {{ resultCount }} result{{ resultCount === 1 ? '' : 's' }} found
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/market/components/MerchantStore.vue b/src/modules/market/components/MerchantStore.vue
index 854ca9a..2ded586 100644
--- a/src/modules/market/components/MerchantStore.vue
+++ b/src/modules/market/components/MerchantStore.vue
@@ -1,56 +1,56 @@
-
-
-
+
+
+
+
Loading your merchant profile...
-
Checking Merchant Status
-
Loading your merchant profile...
-
-
-
+
+
+
Error Loading Merchant Status
+
{{ merchantCheckError }}
+
+ Try Again
+
-
Error Loading Merchant Status
-
{{ merchantCheckError }}
-
- Try Again
-
-
-
-
-
-
-
Create Your Merchant Profile
-
- Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
-
-
-
-
-
-
Create Merchant Profile
-
-
-
-
-
+
-
-
+
+
+
+
+
+
Create Your Merchant Profile
+
+ Before you can create a store, you need to set up your merchant profile. This will create your merchant identity on the Nostr marketplace.
+
+
+
+
+
+
Create Merchant Profile
+
+
+
+
+
+
+
+
My Stores
@@ -258,7 +258,7 @@
{{ product.name }}
-
+
{{ product.config.description }}
@@ -266,7 +266,7 @@
- {{ product.price }} {{ product.config.currency || activeStall?.currency || 'sat' }}
+ {{ product.price }} {{ product.config?.currency || activeStall?.currency || 'sat' }}
Qty: {{ product.quantity }}
@@ -309,22 +309,23 @@
-
-
+
+
-
-
+
+
+
\ No newline at end of file
+
diff --git a/src/modules/market/components/ProductCard.vue b/src/modules/market/components/ProductCard.vue
index 532c074..c570930 100644
--- a/src/modules/market/components/ProductCard.vue
+++ b/src/modules/market/components/ProductCard.vue
@@ -2,12 +2,27 @@
-
+
+
+
+
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()
-// 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 = () => {
diff --git a/src/modules/market/components/ProductDetailDialog.vue b/src/modules/market/components/ProductDetailDialog.vue
new file mode 100644
index 0000000..a94fe84
--- /dev/null
+++ b/src/modules/market/components/ProductDetailDialog.vue
@@ -0,0 +1,280 @@
+
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
+
+
+
{{ product.name }}
+
+
+ {{ formatPrice(product.price, product.currency) }}
+
+
+ Out of Stock
+
+
+ Only {{ product.quantity }} left
+
+
+ In Stock
+
+
+
+
+
+
+
+ Sold by
+ {{ product.stallName }}
+
+
+
+
+
Description
+
{{ product.description }}
+
+
+
+
+
Categories
+
+
+ {{ category }}
+
+
+
+
+
+
+
+
+
+
+
+ Add to Cart
+
+
+ Continue Shopping
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/market/components/ProductGrid.vue b/src/modules/market/components/ProductGrid.vue
new file mode 100644
index 0000000..9f26806
--- /dev/null
+++ b/src/modules/market/components/ProductGrid.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
+ {{ emptyTitle }}
+ {{ emptyMessage }}
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/market/components/SearchSuggestions.vue b/src/modules/market/components/SearchSuggestions.vue
new file mode 100644
index 0000000..103231c
--- /dev/null
+++ b/src/modules/market/components/SearchSuggestions.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
Try searching for:
+
+
+ {{ suggestion }}
+
+
+
+
+
+
+
+
Recent searches:
+
+ Clear
+
+
+
+
+
+ {{ recent }}
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/modules/market/composables/useCategoryFilter.ts b/src/modules/market/composables/useCategoryFilter.ts
new file mode 100644
index 0000000..d41cd66
--- /dev/null
+++ b/src/modules/market/composables/useCategoryFilter.ts
@@ -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,
+ options: CategoryFilterOptions = {}
+) {
+ // Use Set for O(1) lookups instead of array
+ const selectedCategories = ref>(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(() => {
+ const categoryMap = new Map()
+
+ // 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(() => {
+ 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
\ No newline at end of file
diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts
index 7365bc8..a25d827 100644
--- a/src/modules/market/composables/useMarket.ts
+++ b/src/modules/market/composables/useMarket.ts
@@ -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) {
diff --git a/src/modules/market/composables/useSearchKeyboardShortcuts.ts b/src/modules/market/composables/useSearchKeyboardShortcuts.ts
new file mode 100644
index 0000000..b0bcfb0
--- /dev/null
+++ b/src/modules/market/composables/useSearchKeyboardShortcuts.ts
@@ -0,0 +1,53 @@
+import { onMounted, onUnmounted, type Ref } from 'vue'
+
+export function useSearchKeyboardShortcuts(searchInputRef: Ref) {
+ 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
+ }
+}
\ No newline at end of file
diff --git a/src/modules/market/docs/category-filter-improvements.md b/src/modules/market/docs/category-filter-improvements.md
new file mode 100644
index 0000000..3fcf6ab
--- /dev/null
+++ b/src/modules/market/docs/category-filter-improvements.md
@@ -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
+
+
+
+
+
+
+
+```
+
+**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
+
+
+ {{ category.category }}
+
+
+```
+
+---
+
+### 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
+
+
+
+```
+
+#### Collapsible Groups
+Group categories by type with expand/collapse:
+
+```vue
+
+
+ {{ group.name }} ({{ group.totalCount }})
+
+
+
+
+
+```
+
+#### Tag Cloud Visualization
+Show categories sized by popularity:
+
+```vue
+
+
+ {{ category.category }}
+
+
+```
+
+---
+
+### 8. **Performance Optimizations**
+
+#### Virtual Scrolling
+For markets with 100+ categories:
+
+```vue
+
+
+
+
+
+
+
+```
+
+#### 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
+ }
+}
+```
+
+---
+
+### 10. **Mobile-First Enhancements**
+
+#### Bottom Sheet Interface
+Mobile-optimized category selector:
+
+```vue
+
+
+
+ Filter by Category
+
+
+
+
+
+ Apply Filters
+
+
+
+```
+
+#### Swipe Gestures
+```vue
+
+```
+
+---
+
+## 🛠️ 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*
\ No newline at end of file
diff --git a/src/modules/market/index.ts b/src/modules/market/index.ts
index 24bf032..93b6bd6 100644
--- a/src/modules/market/index.ts
+++ b/src/modules/market/index.ts
@@ -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[],
diff --git a/src/modules/market/services/nostrmarketAPI.ts b/src/modules/market/services/nostrmarketAPI.ts
index ea1ba55..f4cae9f 100644
--- a/src/modules/market/services/nostrmarketAPI.ts
+++ b/src/modules/market/services/nostrmarketAPI.ts
@@ -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 {
- const products = await this.request(
+ async getProducts(walletInkey: string, stallId: string, pending: boolean = false): Promise {
+ const products = await this.request(
`/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 {
- const product = await this.request(
+ ): Promise {
+ const product = await this.request(
'/api/v1/product',
walletAdminkey,
{
@@ -405,9 +392,9 @@ export class NostrmarketAPI extends BaseService {
async updateProduct(
walletAdminkey: string,
productId: string,
- productData: Product
- ): Promise {
- const product = await this.request(
+ productData: ProductApiResponse
+ ): Promise {
+ const product = await this.request(
`/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 {
+ async getProduct(walletInkey: string, productId: string): Promise {
try {
- const product = await this.request(
+ const product = await this.request(
`/api/v1/product/${productId}`,
walletInkey,
{ method: 'GET' }
diff --git a/src/modules/market/stores/market.ts b/src/modules/market/stores/market.ts
index a5a2cee..20c89f5 100644
--- a/src/modules/market/stores/market.ts
+++ b/src/modules/market/stores/market.ts
@@ -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,
diff --git a/src/modules/market/types/market.ts b/src/modules/market/types/market.ts
index 3b038b0..7a3b8ca 100644
--- a/src/modules/market/types/market.ts
+++ b/src/modules/market/types/market.ts
@@ -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'
diff --git a/src/modules/market/views/CheckoutPage.vue b/src/modules/market/views/CheckoutPage.vue
index 29eea64..e8890b4 100644
--- a/src/modules/market/views/CheckoutPage.vue
+++ b/src/modules/market/views/CheckoutPage.vue
@@ -54,13 +54,19 @@
-
-
+
@@ -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 => {
diff --git a/src/modules/market/views/MarketPage.vue b/src/modules/market/views/MarketPage.vue
index 9fa6d47..8d4e15d 100644
--- a/src/modules/market/views/MarketPage.vue
+++ b/src/modules/market/views/MarketPage.vue
@@ -1,121 +1,146 @@
-
-
-
-
-
- {{ marketPreloader.isPreloading ? 'Preloading market...' : 'Loading market...' }}
-
-
-
+
+
+
+
+
+
+
+
+
+ M
+
+
+
+ {{ marketStore.activeMarket?.opts?.name || 'Market' }}
+
+
+ {{ marketStore.activeMarket.opts.description }}
+
+
+
-
-
-
-
Failed to load market
-
{{ marketStore.error || marketPreloader.preloadError }}
-
- Try Again
-
-
-
-
-
-
-
-
-
-
-
- M
-
-
-
- {{ marketStore.activeMarket?.opts?.name || 'Market' }}
-
-
- {{ marketStore.activeMarket.opts.description }}
-
+
+
+
-
-
-
-
-
-
-
-
-
- {{ category.category }}
- ({{ category.count }})
-
-
-
-
-
-
No products found
-
Try adjusting your search or filters
-
-
-
+
+
-
-
-
-
- Cart ({{ marketStore.totalCartItems }})
-
-
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file