diff --git a/src/components/ui/ProgressiveImage.vue b/src/components/ui/ProgressiveImage.vue new file mode 100644 index 0000000..042546c --- /dev/null +++ b/src/components/ui/ProgressiveImage.vue @@ -0,0 +1,351 @@ + + + + + + diff --git a/src/modules/market/components/CartButton.vue b/src/modules/market/components/CartButton.vue new file mode 100644 index 0000000..6377564 --- /dev/null +++ b/src/modules/market/components/CartButton.vue @@ -0,0 +1,23 @@ + + + \ No newline at end of file diff --git a/src/modules/market/components/CartItem.vue b/src/modules/market/components/CartItem.vue index 36157ac..a7a79bd 100644 --- a/src/modules/market/components/CartItem.vue +++ b/src/modules/market/components/CartItem.vue @@ -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" /> @@ -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" /> diff --git a/src/modules/market/components/CartSummary.vue b/src/modules/market/components/CartSummary.vue index 245e513..b4b4249 100644 --- a/src/modules/market/components/CartSummary.vue +++ b/src/modules/market/components/CartSummary.vue @@ -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" />

{{ item.product.name }}

diff --git a/src/modules/market/components/CategoryFilterBar.vue b/src/modules/market/components/CategoryFilterBar.vue new file mode 100644 index 0000000..4e26178 --- /dev/null +++ b/src/modules/market/components/CategoryFilterBar.vue @@ -0,0 +1,162 @@ + + + \ No newline at end of file diff --git a/src/modules/market/components/CategoryInput.vue b/src/modules/market/components/CategoryInput.vue new file mode 100644 index 0000000..e836320 --- /dev/null +++ b/src/modules/market/components/CategoryInput.vue @@ -0,0 +1,286 @@ + + + + + \ No newline at end of file diff --git a/src/modules/market/components/CreateProductDialog.vue b/src/modules/market/components/CreateProductDialog.vue index de94285..93d6042 100644 --- a/src/modules/market/components/CreateProductDialog.vue +++ b/src/modules/market/components/CreateProductDialog.vue @@ -89,8 +89,9 @@
@@ -104,14 +105,20 @@
- + Categories Add categories to help customers find your product -
- -

Category management coming soon

-
+ + +
@@ -136,8 +143,8 @@
@@ -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 @@ + + + + + \ 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 @@ + + + + + \ 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 @@ + + + + + \ 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 @@ \ 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 @@
- + + + +
+
+ + No image available +
+
+
+``` + +--- + +### 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 + + + + + + + + + +``` + +#### 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 @@ + + \ No newline at end of file