From 4d3d69f527322d05bcb55ba2e34af05723b1fdcc Mon Sep 17 00:00:00 2001 From: padreug Date: Sat, 2 Aug 2025 16:50:25 +0200 Subject: [PATCH] feat: Implement market functionality with ProductCard, useMarket composable, and market store - Add ProductCard.vue component for displaying product details, including image, name, description, price, and stock status. - Create useMarket.ts composable to manage market loading, data fetching, and real-time updates from Nostr. - Introduce market.ts store to handle market, stall, product, and order states, along with filtering and sorting capabilities. - Develop Market.vue page to present market content, including loading states, error handling, and product grid. - Update router to include a new market route for user navigation. --- src/components/market/ProductCard.vue | 146 ++++++++++ src/composables/useMarket.ts | 367 +++++++++++++++++++++++++ src/lib/config/index.ts | 30 ++- src/pages/Market.vue | 159 +++++++++++ src/router/index.ts | 9 + src/stores/market.ts | 369 ++++++++++++++++++++++++++ 6 files changed, 1079 insertions(+), 1 deletion(-) create mode 100644 src/components/market/ProductCard.vue create mode 100644 src/composables/useMarket.ts create mode 100644 src/pages/Market.vue create mode 100644 src/stores/market.ts diff --git a/src/components/market/ProductCard.vue b/src/components/market/ProductCard.vue new file mode 100644 index 0000000..07c2398 --- /dev/null +++ b/src/components/market/ProductCard.vue @@ -0,0 +1,146 @@ + + + + + \ No newline at end of file diff --git a/src/composables/useMarket.ts b/src/composables/useMarket.ts new file mode 100644 index 0000000..d357f75 --- /dev/null +++ b/src/composables/useMarket.ts @@ -0,0 +1,367 @@ +import { ref, computed, readonly } from 'vue' +import { useNostrStore } from '@/stores/nostr' +import { useMarketStore, type Market, type Stall, type Product } from '@/stores/market' +import { config } from '@/lib/config' + +// Nostr event kinds for market functionality +const MARKET_EVENT_KINDS = { + MARKET: 30019, + STALL: 30017, + PRODUCT: 30018, + ORDER: 30020 +} as const + +export function useMarket() { + const nostrStore = useNostrStore() + const marketStore = useMarketStore() + + const isLoading = ref(false) + const error = ref(null) + const isConnected = ref(false) + + // Market loading state + const loadMarket = async (naddr: string) => { + try { + isLoading.value = true + error.value = null + + // Decode naddr + const { type, data } = window.NostrTools.nip19.decode(naddr) + if (type !== 'naddr' || data.kind !== MARKET_EVENT_KINDS.MARKET) { + throw new Error('Invalid market naddr') + } + + // Load market data from Nostr + await loadMarketData(data) + + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to load market' + throw err + } finally { + isLoading.value = false + } + } + + const loadMarketData = async (marketData: any) => { + try { + // Get Nostr client + const client = nostrStore.getClient() + + // Load market configuration + await loadMarketConfig(marketData) + + // Load stalls for this market + await loadStalls(marketData.pubkey) + + // Load products for all stalls + await loadProducts() + + // Subscribe to real-time updates + subscribeToMarketUpdates() + + } catch (err) { + console.error('Error loading market data:', err) + throw err + } + } + + const loadMarketConfig = async (marketData: any) => { + try { + const client = nostrStore.getClient() + + // Fetch market configuration event + const filters = [{ + kinds: [MARKET_EVENT_KINDS.MARKET], + authors: [marketData.pubkey], + '#d': [marketData.d] + }] + + const events = await client.fetchNotes({ filters }) + + if (events.length > 0) { + const marketEvent = events[0] + const market: Market = { + d: marketData.d, + pubkey: marketData.pubkey, + relays: config.market.supportedRelays, + selected: true, + opts: JSON.parse(marketEvent.content) + } + + marketStore.addMarket(market) + marketStore.setActiveMarket(market) + } + + } catch (err) { + console.error('Error loading market config:', err) + throw err + } + } + + const loadStalls = async (marketPubkey: string) => { + try { + const client = nostrStore.getClient() + + // Fetch stall events for this market + const filters = [{ + kinds: [MARKET_EVENT_KINDS.STALL], + authors: [marketPubkey] + }] + + const events = await client.fetchNotes({ filters }) + + events.forEach(event => { + try { + const stallData = JSON.parse(event.content) + const stall: Stall = { + id: event.id, + pubkey: event.pubkey, + name: stallData.name, + description: stallData.description, + logo: stallData.logo, + categories: stallData.categories, + shipping: stallData.shipping + } + + marketStore.addStall(stall) + } catch (err) { + console.warn('Failed to parse stall event:', err) + } + }) + + } catch (err) { + console.error('Error loading stalls:', err) + throw err + } + } + + const loadProducts = async () => { + try { + const client = nostrStore.getClient() + + // Get all stall pubkeys + const stallPubkeys = marketStore.stalls.map(stall => stall.pubkey) + + if (stallPubkeys.length === 0) return + + // Fetch product events from all stalls + const filters = [{ + kinds: [MARKET_EVENT_KINDS.PRODUCT], + authors: stallPubkeys + }] + + const events = await client.fetchNotes({ filters }) + + events.forEach(event => { + try { + const productData = JSON.parse(event.content) + const stall = marketStore.stalls.find(s => s.pubkey === event.pubkey) + + if (stall) { + const product: Product = { + id: event.id, + stall_id: stall.id, + stallName: stall.name, + name: productData.name, + description: productData.description, + price: productData.price, + currency: productData.currency, + quantity: productData.quantity, + images: productData.images, + categories: productData.categories, + createdAt: event.created_at, + updatedAt: event.created_at + } + + marketStore.addProduct(product) + } + } catch (err) { + console.warn('Failed to parse product event:', err) + } + }) + + } catch (err) { + console.error('Error loading products:', err) + throw err + } + } + + const subscribeToMarketUpdates = () => { + try { + const client = nostrStore.getClient() + + // Subscribe to real-time market updates + const filters = [ + { + kinds: [MARKET_EVENT_KINDS.STALL, MARKET_EVENT_KINDS.PRODUCT], + since: Math.floor(Date.now() / 1000) + } + ] + + const unsubscribe = client.subscribeToNotes((event) => { + handleMarketEvent(event) + }, filters) + + // Store unsubscribe function for cleanup + return unsubscribe + + } catch (err) { + console.error('Error subscribing to market updates:', err) + } + } + + const handleMarketEvent = (event: any) => { + try { + switch (event.kind) { + case MARKET_EVENT_KINDS.STALL: + handleStallEvent(event) + break + case MARKET_EVENT_KINDS.PRODUCT: + handleProductEvent(event) + break + case MARKET_EVENT_KINDS.ORDER: + handleOrderEvent(event) + break + } + } catch (err) { + console.error('Error handling market event:', err) + } + } + + const handleStallEvent = (event: any) => { + try { + const stallData = JSON.parse(event.content) + const stall: Stall = { + id: event.id, + pubkey: event.pubkey, + name: stallData.name, + description: stallData.description, + logo: stallData.logo, + categories: stallData.categories, + shipping: stallData.shipping + } + + marketStore.addStall(stall) + } catch (err) { + console.warn('Failed to parse stall event:', err) + } + } + + const handleProductEvent = (event: any) => { + try { + const productData = JSON.parse(event.content) + const stall = marketStore.stalls.find(s => s.pubkey === event.pubkey) + + if (stall) { + const product: Product = { + id: event.id, + stall_id: stall.id, + stallName: stall.name, + name: productData.name, + description: productData.description, + price: productData.price, + currency: productData.currency, + quantity: productData.quantity, + images: productData.images, + categories: productData.categories, + createdAt: event.created_at, + updatedAt: event.created_at + } + + marketStore.addProduct(product) + } + } catch (err) { + console.warn('Failed to parse product event:', err) + } + } + + const handleOrderEvent = (event: any) => { + try { + const orderData = JSON.parse(event.content) + // Handle order events (for future order management) + console.log('Order event received:', orderData) + } catch (err) { + console.warn('Failed to parse order event:', err) + } + } + + const publishProduct = async (productData: any) => { + try { + const client = nostrStore.getClient() + + const event = { + kind: MARKET_EVENT_KINDS.PRODUCT, + content: JSON.stringify(productData), + tags: [ + ['d', productData.id], + ['t', 'product'] + ] + } + + await client.publishEvent(event) + + } catch (err) { + console.error('Error publishing product:', err) + throw err + } + } + + const publishStall = async (stallData: any) => { + try { + const client = nostrStore.getClient() + + const event = { + kind: MARKET_EVENT_KINDS.STALL, + content: JSON.stringify(stallData), + tags: [ + ['d', stallData.id], + ['t', 'stall'] + ] + } + + await client.publishEvent(event) + + } catch (err) { + console.error('Error publishing stall:', err) + throw err + } + } + + const connectToMarket = async () => { + try { + if (!nostrStore.isConnected) { + await nostrStore.connect() + } + + isConnected.value = nostrStore.isConnected + + if (!isConnected.value) { + throw new Error('Failed to connect to Nostr relays') + } + + } catch (err) { + console.error('Error connecting to market:', err) + throw err + } + } + + const disconnectFromMarket = () => { + // Cleanup subscriptions and connections + isConnected.value = false + } + + return { + // State + isLoading: readonly(isLoading), + error: readonly(error), + isConnected: readonly(isConnected), + + // Actions + loadMarket, + connectToMarket, + disconnectFromMarket, + publishProduct, + publishStall, + subscribeToMarketUpdates + } +} \ No newline at end of file diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index cb119b8..aa7601a 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -16,10 +16,18 @@ interface PushConfig { enabled: boolean } +interface MarketConfig { + defaultNaddr: string + supportedRelays: string[] + lightningEnabled: boolean + defaultCurrency: string +} + interface AppConfig { nostr: NostrConfig api: ApiConfig push: PushConfig + market: MarketConfig support: { npub: string } @@ -51,6 +59,18 @@ export const config: AppConfig = { vapidPublicKey: import.meta.env.VITE_VAPID_PUBLIC_KEY || '', enabled: Boolean(import.meta.env.VITE_PUSH_NOTIFICATIONS_ENABLED) }, + market: { + defaultNaddr: import.meta.env.VITE_MARKET_NADDR || '', + supportedRelays: parseJsonEnv(import.meta.env.VITE_MARKET_RELAYS, [ + 'wss://relay.damus.io', + 'wss://relay.snort.social', + 'wss://nostr-pub.wellorder.net', + 'wss://nostr.zebedee.cloud', + 'wss://nostr.walletofsatoshi.com' + ]), + lightningEnabled: Boolean(import.meta.env.VITE_LIGHTNING_ENABLED), + defaultCurrency: import.meta.env.VITE_MARKET_DEFAULT_CURRENCY || 'sat' + }, support: { npub: import.meta.env.VITE_SUPPORT_NPUB || '' } @@ -74,10 +94,18 @@ export const configUtils = { return Boolean(config.push.vapidPublicKey && config.push.enabled) }, + hasMarketConfig: (): boolean => { + return Boolean(config.market.defaultNaddr) + }, + getDefaultRelays: (): string[] => { return config.nostr.relays + }, + + getMarketRelays: (): string[] => { + return config.market.supportedRelays } } // Export individual config sections for convenience -export const { nostr: nostrConfig, api: apiConfig } = config \ No newline at end of file +export const { nostr: nostrConfig, api: apiConfig, market: marketConfig } = config \ No newline at end of file diff --git a/src/pages/Market.vue b/src/pages/Market.vue new file mode 100644 index 0000000..e49b005 --- /dev/null +++ b/src/pages/Market.vue @@ -0,0 +1,159 @@ + + + \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index 1ed3bca..490aa71 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -39,6 +39,15 @@ const router = createRouter({ title: 'My Tickets', requiresAuth: true } + }, + { + path: '/market', + name: 'market', + component: () => import('@/pages/Market.vue'), + meta: { + title: 'Market', + requiresAuth: true + } } ] }) diff --git a/src/stores/market.ts b/src/stores/market.ts new file mode 100644 index 0000000..19004e2 --- /dev/null +++ b/src/stores/market.ts @@ -0,0 +1,369 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { config } from '@/lib/config' + +// Types +export interface Market { + d: string + pubkey: string + relays: string[] + selected: boolean + opts: { + name: string + description?: string + logo?: string + banner?: string + merchants: string[] + ui: Record + } +} + +export interface Stall { + id: string + pubkey: string + name: string + description?: string + logo?: string + categories?: string[] + shipping?: Record +} + +export interface Product { + id: string + stall_id: string + stallName: string + name: string + description?: string + price: number + currency: string + quantity: number + images?: string[] + categories?: string[] + createdAt: number + updatedAt: number +} + +export interface Order { + id: string + stall_id: string + product_id: string + buyer_pubkey: string + seller_pubkey: string + quantity: number + total_price: number + currency: string + status: 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' + payment_request?: string + created_at: number + updated_at: number +} + +export interface FilterData { + categories: string[] + merchants: string[] + stalls: string[] + currency: string | null + priceFrom: number | null + priceTo: number | null +} + +export interface SortOptions { + field: string + order: 'asc' | 'desc' +} + +export const useMarketStore = defineStore('market', () => { + // Core market state + const markets = ref([]) + const stalls = ref([]) + const products = ref([]) + const orders = ref>({}) + const profiles = ref>({}) + + // Active selections + const activeMarket = ref(null) + const activeStall = ref(null) + const activeProduct = ref(null) + + // UI state + const isLoading = ref(false) + const searchText = ref('') + const showFilterDetails = ref(false) + + // Filtering and sorting + const filterData = ref({ + categories: [], + merchants: [], + stalls: [], + currency: null, + priceFrom: null, + priceTo: null + }) + + const sortOptions = ref({ + field: 'name', + order: 'asc' + }) + + // Shopping cart + const shoppingCart = ref>({}) + + // Computed properties + const filteredProducts = computed(() => { + let filtered = products.value + + // Search filter + if (searchText.value) { + const searchLower = searchText.value.toLowerCase() + filtered = filtered.filter(product => + product.name.toLowerCase().includes(searchLower) || + product.description?.toLowerCase().includes(searchLower) || + product.stallName.toLowerCase().includes(searchLower) + ) + } + + // Category filter + if (filterData.value.categories.length > 0) { + filtered = filtered.filter(product => + product.categories?.some(cat => filterData.value.categories.includes(cat)) + ) + } + + // Merchant filter + if (filterData.value.merchants.length > 0) { + filtered = filtered.filter(product => + filterData.value.merchants.includes(product.stall_id) + ) + } + + // Stall filter + if (filterData.value.stalls.length > 0) { + filtered = filtered.filter(product => + filterData.value.stalls.includes(product.stall_id) + ) + } + + // Currency filter + if (filterData.value.currency) { + filtered = filtered.filter(product => + product.currency === filterData.value.currency + ) + } + + // Price range filter + if (filterData.value.priceFrom !== null) { + filtered = filtered.filter(product => + product.price >= filterData.value.priceFrom! + ) + } + + if (filterData.value.priceTo !== null) { + filtered = filtered.filter(product => + product.price <= filterData.value.priceTo! + ) + } + + // Sort + filtered.sort((a, b) => { + const aVal = a[sortOptions.value.field as keyof Product] + const bVal = b[sortOptions.value.field as keyof Product] + + if (typeof aVal === 'string' && typeof bVal === 'string') { + return sortOptions.value.order === 'asc' + ? aVal.localeCompare(bVal) + : bVal.localeCompare(aVal) + } + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return sortOptions.value.order === 'asc' + ? aVal - bVal + : bVal - aVal + } + + return 0 + }) + + return filtered + }) + + const allCategories = computed(() => { + const categories = new Set() + products.value.forEach(product => { + product.categories?.forEach(cat => categories.add(cat)) + }) + return Array.from(categories).map(category => ({ + category, + count: products.value.filter(p => p.categories?.includes(category)).length, + selected: filterData.value.categories.includes(category) + })) + }) + + const cartTotal = computed(() => { + return Object.values(shoppingCart.value).reduce((total, item) => { + return total + (item.product.price * item.quantity) + }, 0) + }) + + const cartItemCount = computed(() => { + return Object.values(shoppingCart.value).reduce((count, item) => { + return count + item.quantity + }, 0) + }) + + // Actions + const setLoading = (loading: boolean) => { + isLoading.value = loading + } + + const setSearchText = (text: string) => { + searchText.value = text + } + + const setActiveMarket = (market: Market | null) => { + activeMarket.value = market + } + + const setActiveStall = (stall: Stall | null) => { + activeStall.value = stall + } + + const setActiveProduct = (product: Product | null) => { + activeProduct.value = product + } + + const addProduct = (product: Product) => { + const existingIndex = products.value.findIndex(p => p.id === product.id) + if (existingIndex >= 0) { + products.value[existingIndex] = product + } else { + products.value.push(product) + } + } + + const addStall = (stall: Stall) => { + const existingIndex = stalls.value.findIndex(s => s.id === stall.id) + if (existingIndex >= 0) { + stalls.value[existingIndex] = stall + } else { + stalls.value.push(stall) + } + } + + const addMarket = (market: Market) => { + const existingIndex = markets.value.findIndex(m => m.d === market.d) + if (existingIndex >= 0) { + markets.value[existingIndex] = market + } else { + markets.value.push(market) + } + } + + const addToCart = (product: Product, quantity: number = 1) => { + const existing = shoppingCart.value[product.id] + if (existing) { + existing.quantity += quantity + } else { + shoppingCart.value[product.id] = { product, quantity } + } + } + + const removeFromCart = (productId: string) => { + delete shoppingCart.value[productId] + } + + const updateCartQuantity = (productId: string, quantity: number) => { + if (quantity <= 0) { + removeFromCart(productId) + } else { + const item = shoppingCart.value[productId] + if (item) { + item.quantity = quantity + } + } + } + + const clearCart = () => { + shoppingCart.value = {} + } + + const updateFilterData = (newFilterData: Partial) => { + filterData.value = { ...filterData.value, ...newFilterData } + } + + const clearFilters = () => { + filterData.value = { + categories: [], + merchants: [], + stalls: [], + currency: null, + priceFrom: null, + priceTo: null + } + } + + const toggleCategoryFilter = (category: string) => { + const index = filterData.value.categories.indexOf(category) + if (index >= 0) { + filterData.value.categories.splice(index, 1) + } else { + filterData.value.categories.push(category) + } + } + + const updateSortOptions = (field: string, order: 'asc' | 'desc' = 'asc') => { + sortOptions.value = { field, order } + } + + const formatPrice = (price: number, currency: string) => { + if (currency === 'sat') { + return `${price} sats` + } + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currency.toUpperCase() + }).format(price) + } + + return { + // State + markets: readonly(markets), + stalls: readonly(stalls), + products: readonly(products), + orders: readonly(orders), + profiles: readonly(profiles), + activeMarket: readonly(activeMarket), + activeStall: readonly(activeStall), + activeProduct: readonly(activeProduct), + isLoading: readonly(isLoading), + searchText: readonly(searchText), + showFilterDetails: readonly(showFilterDetails), + filterData: readonly(filterData), + sortOptions: readonly(sortOptions), + shoppingCart: readonly(shoppingCart), + + // Computed + filteredProducts, + allCategories, + cartTotal, + cartItemCount, + + // Actions + setLoading, + setSearchText, + setActiveMarket, + setActiveStall, + setActiveProduct, + addProduct, + addStall, + addMarket, + addToCart, + removeFromCart, + updateCartQuantity, + clearCart, + updateFilterData, + clearFilters, + toggleCategoryFilter, + updateSortOptions, + formatPrice + } +}) \ No newline at end of file