From e40ac91417e15c7af664603d96d70de79fb43e12 Mon Sep 17 00:00:00 2001 From: padreug Date: Fri, 5 Sep 2025 00:01:40 +0200 Subject: [PATCH] Enhance market module with new chat and events features - Introduce chat module with components, services, and composables for real-time messaging. - Implement events module with API service, components, and ticket purchasing functionality. - Update app configuration to include new modules and their respective settings. - Refactor existing components to integrate with the new chat and events features. - Enhance market store and services to support new functionalities and improve order management. - Update routing to accommodate new views for chat and events, ensuring seamless navigation. --- src/app.config.ts | 4 + src/app.ts | 27 +- src/components/market/CartSummary.vue | 235 +--- src/components/market/PaymentDisplay.vue | 338 +----- src/components/market/ShoppingCart.vue | 254 +--- src/composables/useMarket.ts | 537 +-------- src/composables/useMarketPreloader.ts | 63 +- src/lib/services/nostrmarketService.ts | 463 +------- src/modules/chat/components/ChatComponent.vue | 627 ++++++++++ src/modules/chat/composables/useChat.ts | 82 ++ src/modules/chat/index.ts | 106 ++ src/modules/chat/services/chat-service.ts | 240 ++++ src/modules/chat/types/index.ts | 57 + src/modules/chat/views/ChatPage.vue | 9 + .../components/PurchaseTicketDialog.vue | 255 ++++ src/modules/events/composables/useEvents.ts | 65 ++ .../events/composables/useTicketPurchase.ts | 242 ++++ .../events/composables/useUserTickets.ts | 123 ++ src/modules/events/index.ts | 116 ++ src/modules/events/services/events-api.ts | 155 +++ src/modules/events/types/event.ts | 36 + src/modules/events/views/EventsPage.vue | 168 +++ src/modules/events/views/MyTicketsPage.vue | 599 ++++++++++ .../market/components}/CartItem.vue | 2 +- src/modules/market/components/CartSummary.vue | 231 ++++ .../market/components}/DashboardOverview.vue | 0 .../market/components}/MarketSettings.vue | 0 .../market/components}/MerchantStore.vue | 0 .../market/components}/OrderHistory.vue | 0 .../market/components/PaymentDisplay.vue | 334 ++++++ .../components}/PaymentRequestDialog.vue | 0 .../market/components}/ProductCard.vue | 0 .../market/components/ShoppingCart.vue | 250 ++++ src/modules/market/composables/index.ts | 2 + src/modules/market/composables/useMarket.ts | 538 +++++++++ .../market/composables/useMarketPreloader.ts | 60 + src/modules/market/index.ts | 143 +++ .../market/services/nostrmarketService.ts | 460 ++++++++ src/modules/market/stores/market.ts | 884 ++++++++++++++ src/modules/market/types/market.ts | 150 +++ src/modules/market/views/MarketDashboard.vue | 125 ++ src/modules/market/views/MarketPage.vue | 187 +++ src/pages/Market.vue | 191 +-- src/pages/MarketDashboard.vue | 129 +- src/router/index.ts | 42 - src/stores/market.ts | 1040 +---------------- 46 files changed, 6305 insertions(+), 3264 deletions(-) create mode 100644 src/modules/chat/components/ChatComponent.vue create mode 100644 src/modules/chat/composables/useChat.ts create mode 100644 src/modules/chat/index.ts create mode 100644 src/modules/chat/services/chat-service.ts create mode 100644 src/modules/chat/types/index.ts create mode 100644 src/modules/chat/views/ChatPage.vue create mode 100644 src/modules/events/components/PurchaseTicketDialog.vue create mode 100644 src/modules/events/composables/useEvents.ts create mode 100644 src/modules/events/composables/useTicketPurchase.ts create mode 100644 src/modules/events/composables/useUserTickets.ts create mode 100644 src/modules/events/index.ts create mode 100644 src/modules/events/services/events-api.ts create mode 100644 src/modules/events/types/event.ts create mode 100644 src/modules/events/views/EventsPage.vue create mode 100644 src/modules/events/views/MyTicketsPage.vue rename src/{components/market => modules/market/components}/CartItem.vue (99%) create mode 100644 src/modules/market/components/CartSummary.vue rename src/{components/market => modules/market/components}/DashboardOverview.vue (100%) rename src/{components/market => modules/market/components}/MarketSettings.vue (100%) rename src/{components/market => modules/market/components}/MerchantStore.vue (100%) rename src/{components/market => modules/market/components}/OrderHistory.vue (100%) create mode 100644 src/modules/market/components/PaymentDisplay.vue rename src/{components/market => modules/market/components}/PaymentRequestDialog.vue (100%) rename src/{components/market => modules/market/components}/ProductCard.vue (100%) create mode 100644 src/modules/market/components/ShoppingCart.vue create mode 100644 src/modules/market/composables/index.ts create mode 100644 src/modules/market/composables/useMarket.ts create mode 100644 src/modules/market/composables/useMarketPreloader.ts create mode 100644 src/modules/market/index.ts create mode 100644 src/modules/market/services/nostrmarketService.ts create mode 100644 src/modules/market/stores/market.ts create mode 100644 src/modules/market/types/market.ts create mode 100644 src/modules/market/views/MarketDashboard.vue create mode 100644 src/modules/market/views/MarketPage.vue diff --git a/src/app.config.ts b/src/app.config.ts index 1280fff..dfee834 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -54,6 +54,10 @@ export const appConfig: AppConfig = { enabled: true, lazy: false, config: { + apiConfig: { + baseUrl: 'http://lnbits', + apiKey: 'your-api-key-here' + }, ticketValidationEndpoint: '/api/tickets/validate', maxTicketsPerUser: 10 } diff --git a/src/app.ts b/src/app.ts index e16bfe2..026f288 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,6 +12,9 @@ import appConfig from './app.config' // Base modules import baseModule from './modules/base' import nostrFeedModule from './modules/nostr-feed' +import chatModule from './modules/chat' +import eventsModule from './modules/events' +import marketModule from './modules/market' // Root component import App from './App.vue' @@ -81,10 +84,26 @@ export async function createAppInstance() { ) } - // TODO: Register other modules as they're converted - // - market module - // - chat module - // - events module + // Register chat module + if (appConfig.modules.chat.enabled) { + moduleRegistrations.push( + pluginManager.register(chatModule, appConfig.modules.chat) + ) + } + + // Register events module + if (appConfig.modules.events.enabled) { + moduleRegistrations.push( + pluginManager.register(eventsModule, appConfig.modules.events) + ) + } + + // Register market module + if (appConfig.modules.market.enabled) { + moduleRegistrations.push( + pluginManager.register(marketModule, appConfig.modules.market) + ) + } // Wait for all modules to register await Promise.all(moduleRegistrations) diff --git a/src/components/market/CartSummary.vue b/src/components/market/CartSummary.vue index abb9d7d..9db6fe1 100644 --- a/src/components/market/CartSummary.vue +++ b/src/components/market/CartSummary.vue @@ -1,231 +1,4 @@ - - - + \ No newline at end of file diff --git a/src/components/market/PaymentDisplay.vue b/src/components/market/PaymentDisplay.vue index 01187f1..c5ea4eb 100644 --- a/src/components/market/PaymentDisplay.vue +++ b/src/components/market/PaymentDisplay.vue @@ -1,334 +1,4 @@ - - - + \ No newline at end of file diff --git a/src/components/market/ShoppingCart.vue b/src/components/market/ShoppingCart.vue index 05ae898..4790f7e 100644 --- a/src/components/market/ShoppingCart.vue +++ b/src/components/market/ShoppingCart.vue @@ -1,250 +1,4 @@ - - - + \ No newline at end of file diff --git a/src/composables/useMarket.ts b/src/composables/useMarket.ts index 3fafbcb..9e99d1f 100644 --- a/src/composables/useMarket.ts +++ b/src/composables/useMarket.ts @@ -1,534 +1,3 @@ -import { ref, computed, onMounted, onUnmounted, readonly } from 'vue' -import { useNostrStore } from '@/stores/nostr' -import { useMarketStore } from '@/stores/market' -import { relayHubComposable } from './useRelayHub' -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 relayHub = relayHubComposable - - // State - const isLoading = ref(false) - const error = ref(null) - const isConnected = ref(false) - const activeMarket = computed(() => marketStore.activeMarket) - const markets = computed(() => marketStore.markets) - const stalls = computed(() => marketStore.stalls) - const products = computed(() => marketStore.products) - const orders = computed(() => marketStore.orders) - - // Connection state - const connectionStatus = computed(() => { - if (isConnected.value) return 'connected' - if (nostrStore.isConnecting) return 'connecting' - if (nostrStore.error) return 'error' - return 'disconnected' - }) - - // Load market from naddr - const loadMarket = async (naddr: string) => { - try { - isLoading.value = true - error.value = null - - // Load market from naddr - - // Parse naddr to get market data - const marketData = { - identifier: naddr.split(':')[2] || 'default', - pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || '' - } - - if (!marketData.pubkey) { - throw new Error('No pubkey available for market') - } - - await loadMarketData(marketData) - - } catch (err) { - error.value = err instanceof Error ? err : new Error('Failed to load market') - throw err - } finally { - isLoading.value = false - } - } - - // Load market data from Nostr events - const loadMarketData = async (marketData: any) => { - try { - // Load market data from Nostr events - - // Fetch market configuration event - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.MARKET], - authors: [marketData.pubkey], - '#d': [marketData.identifier] - } - ]) - - // Process market events - - if (events.length > 0) { - const marketEvent = events[0] - // Process market event - - const market = { - d: marketData.identifier, - pubkey: marketData.pubkey, - relays: config.market.supportedRelays, - selected: true, - opts: JSON.parse(marketEvent.content) - } - - marketStore.addMarket(market) - marketStore.setActiveMarket(market) - } else { - // No market events found, create default - // Create a default market if none exists - const market = { - d: marketData.identifier, - pubkey: marketData.pubkey, - relays: config.market.supportedRelays, - selected: true, - opts: { - name: 'Ariège Market', - description: 'A communal market to sell your goods', - merchants: [], - ui: {} - } - } - - marketStore.addMarket(market) - marketStore.setActiveMarket(market) - } - - } catch (err) { - // Don't throw error, create default market instead - const market = { - d: marketData.identifier, - pubkey: marketData.pubkey, - relays: config.market.supportedRelays, - selected: true, - opts: { - name: 'Default Market', - description: 'A default market', - merchants: [], - ui: {} - } - } - - marketStore.addMarket(market) - marketStore.setActiveMarket(market) - } - } - - // Load stalls from market merchants - const loadStalls = async () => { - try { - // Get the active market to filter by its merchants - const activeMarket = marketStore.activeMarket - if (!activeMarket) { - return - } - - const merchants = [...(activeMarket.opts.merchants || [])] - - if (merchants.length === 0) { - return - } - - // Fetch stall events from market merchants only - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.STALL], - authors: merchants - } - ]) - - // Process stall events - - // Group events by stall ID and keep only the most recent version - const stallGroups = new Map() - events.forEach((event: any) => { - const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] - if (stallId) { - if (!stallGroups.has(stallId)) { - stallGroups.set(stallId, []) - } - stallGroups.get(stallId)!.push(event) - } - }) - - // Process each stall group - stallGroups.forEach((stallEvents, stallId) => { - // Sort by created_at and take the most recent - const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0] - - try { - const stallData = JSON.parse(latestEvent.content) - const stall = { - id: stallId, - pubkey: latestEvent.pubkey, - name: stallData.name || 'Unnamed Stall', - description: stallData.description || '', - created_at: latestEvent.created_at, - ...stallData - } - - marketStore.addStall(stall) - } catch (err) { - // Silently handle parse errors - } - }) - - } catch (err) { - // Silently handle stall loading errors - } - } - - // Load products from market stalls - const loadProducts = async () => { - try { - const activeMarket = marketStore.activeMarket - if (!activeMarket) { - return - } - - const merchants = [...(activeMarket.opts.merchants || [])] - if (merchants.length === 0) { - return - } - - // Fetch product events from market merchants - const events = await relayHub.queryEvents([ - { - kinds: [MARKET_EVENT_KINDS.PRODUCT], - authors: merchants - } - ]) - - // Process product events - - // Group events by product ID and keep only the most recent version - const productGroups = new Map() - events.forEach((event: any) => { - const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] - if (productId) { - if (!productGroups.has(productId)) { - productGroups.set(productId, []) - } - productGroups.get(productId)!.push(event) - } - }) - - // Process each product group - 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 product = { - id: productId, - stall_id: productData.stall_id || 'unknown', - stallName: productData.stallName || 'Unknown Stall', - 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 || [], - createdAt: latestEvent.created_at, - updatedAt: latestEvent.created_at - } - - marketStore.addProduct(product) - } catch (err) { - // Silently handle parse errors - } - }) - - } catch (err) { - // Silently handle product loading errors - } - } - - // Add sample products for testing - const addSampleProducts = () => { - const sampleProducts = [ - { - id: 'sample-1', - stall_id: 'sample-stall', - stallName: 'Sample Stall', - pubkey: nostrStore.account?.pubkey || '', - name: 'Sample Product 1', - description: 'This is a sample product for testing', - price: 1000, - currency: 'sats', - quantity: 1, - images: [], - categories: [], - createdAt: Math.floor(Date.now() / 1000), - updatedAt: Math.floor(Date.now() / 1000) - }, - { - id: 'sample-2', - stall_id: 'sample-stall', - stallName: 'Sample Stall', - pubkey: nostrStore.account?.pubkey || '', - name: 'Sample Product 2', - description: 'Another sample product for testing', - price: 2000, - currency: 'sats', - quantity: 1, - images: [], - categories: [], - createdAt: Math.floor(Date.now() / 1000), - updatedAt: Math.floor(Date.now() / 1000) - } - ] - - sampleProducts.forEach(product => { - marketStore.addProduct(product) - }) - } - - // Subscribe to market updates - const subscribeToMarketUpdates = (): (() => void) | null => { - try { - const activeMarket = marketStore.activeMarket - if (!activeMarket) { - return null - } - - // Subscribe to market events - const unsubscribe = relayHub.subscribe({ - id: `market-${activeMarket.d}`, - filters: [ - { kinds: [MARKET_EVENT_KINDS.MARKET] }, - { kinds: [MARKET_EVENT_KINDS.STALL] }, - { kinds: [MARKET_EVENT_KINDS.PRODUCT] }, - { kinds: [MARKET_EVENT_KINDS.ORDER] } - ], - onEvent: (event: any) => { - handleMarketEvent(event) - } - }) - - return unsubscribe - } catch (error) { - return null - } - } - - // Handle incoming market events - const handleMarketEvent = (event: any) => { - // Process market event - - switch (event.kind) { - case MARKET_EVENT_KINDS.MARKET: - // Handle market updates - break - case MARKET_EVENT_KINDS.STALL: - // Handle stall updates - handleStallEvent(event) - break - case MARKET_EVENT_KINDS.PRODUCT: - // Handle product updates - handleProductEvent(event) - break - case MARKET_EVENT_KINDS.ORDER: - // Handle order updates - handleOrderEvent(event) - break - } - } - - // Process pending products (products without stalls) - const processPendingProducts = () => { - const productsWithoutStalls = products.value.filter(product => { - // Check if product has a stall tag - return !product.stall_id - }) - - if (productsWithoutStalls.length > 0) { - // You could create default stalls or handle this as needed - } - } - - // Handle stall events - const handleStallEvent = (event: any) => { - try { - const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] - if (stallId) { - const stallData = JSON.parse(event.content) - const stall = { - id: stallId, - pubkey: event.pubkey, - name: stallData.name || 'Unnamed Stall', - description: stallData.description || '', - created_at: event.created_at, - ...stallData - } - - marketStore.addStall(stall) - } - } catch (err) { - // Silently handle stall event errors - } - } - - // Handle product events - const handleProductEvent = (event: any) => { - try { - const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] - if (productId) { - const productData = JSON.parse(event.content) - const product = { - id: productId, - stall_id: productData.stall_id || 'unknown', - stallName: productData.stallName || 'Unknown Stall', - pubkey: event.pubkey, - 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 || [], - createdAt: event.created_at, - updatedAt: event.created_at - } - - marketStore.addProduct(product) - } - } catch (err) { - // Silently handle product event errors - } - } - - // Handle order events - const handleOrderEvent = (_event: any) => { - try { - // const orderData = JSON.parse(event.content) - // const order = { - // id: event.id, - // stall_id: orderData.stall_id || 'unknown', - // product_id: orderData.product_id || 'unknown', - // buyer_pubkey: event.pubkey, - // seller_pubkey: orderData.seller_pubkey || '', - // quantity: orderData.quantity || 1, - // total_price: orderData.total_price || 0, - // currency: orderData.currency || 'sats', - // status: orderData.status || 'pending', - // payment_request: orderData.payment_request, - // created_at: event.created_at, - // updated_at: event.created_at - // } - - // Note: addOrder method doesn't exist in the store, so we'll just handle it silently - } catch (err) { - // Silently handle order event errors - } - } - - // Publish a product - const publishProduct = async (_productData: any) => { - // Implementation would depend on your event creation logic - // TODO: Implement product publishing - } - - // Publish a stall - const publishStall = async (_stallData: any) => { - // Implementation would depend on your event creation logic - // TODO: Implement stall publishing - } - - // Connect to market - const connectToMarket = async () => { - try { - // Connect to market - - // Connect to relay hub - await relayHub.connect() - isConnected.value = relayHub.isConnected.value - - if (!isConnected.value) { - throw new Error('Failed to connect to Nostr relays') - } - - // Market connected successfully - - // Load market data - await loadMarketData({ - identifier: 'default', - pubkey: nostrStore.account?.pubkey || '' - }) - - // Load stalls and products - await loadStalls() - await loadProducts() - - // Subscribe to updates - subscribeToMarketUpdates() - - } catch (err) { - error.value = err instanceof Error ? err : new Error('Failed to connect to market') - throw err - } - } - - // Disconnect from market - const disconnectFromMarket = () => { - isConnected.value = false - error.value = null - // Market disconnected - } - - // Initialize market on mount - onMounted(async () => { - if (nostrStore.isConnected) { - await connectToMarket() - } - }) - - // Cleanup on unmount - onUnmounted(() => { - disconnectFromMarket() - }) - - return { - // State - isLoading: readonly(isLoading), - error: readonly(error), - isConnected: readonly(isConnected), - connectionStatus: readonly(connectionStatus), - activeMarket: readonly(activeMarket), - markets: readonly(markets), - stalls: readonly(stalls), - products: readonly(products), - orders: readonly(orders), - - // Actions - loadMarket, - connectToMarket, - disconnectFromMarket, - addSampleProducts, - processPendingProducts, - publishProduct, - publishStall, - subscribeToMarketUpdates - } -} +// Compatibility re-export for the moved useMarket composable +export * from '@/modules/market/composables/useMarket' +export { useMarket } from '@/modules/market/composables/useMarket' \ No newline at end of file diff --git a/src/composables/useMarketPreloader.ts b/src/composables/useMarketPreloader.ts index 3d198b1..3ea23d9 100644 --- a/src/composables/useMarketPreloader.ts +++ b/src/composables/useMarketPreloader.ts @@ -1,60 +1,3 @@ -import { ref, readonly } from 'vue' -import { useMarket } from './useMarket' -import { useMarketStore } from '@/stores/market' -import { config } from '@/lib/config' - -export function useMarketPreloader() { - const isPreloading = ref(false) - const isPreloaded = ref(false) - const preloadError = ref(null) - - const market = useMarket() - const marketStore = useMarketStore() - - const preloadMarket = async () => { - // Don't preload if already done or currently preloading - if (isPreloaded.value || isPreloading.value) { - return - } - - try { - isPreloading.value = true - preloadError.value = null - - const naddr = config.market.defaultNaddr - if (!naddr) { - return - } - - // Connect to market - await market.connectToMarket() - - // Load market data - await market.loadMarket(naddr) - - // Clear any error state since preloading succeeded - marketStore.setError(null) - - isPreloaded.value = true - - } catch (error) { - preloadError.value = error instanceof Error ? error.message : 'Failed to preload market' - // Don't throw error, let the UI handle it gracefully - } finally { - isPreloading.value = false - } - } - - const resetPreload = () => { - isPreloaded.value = false - preloadError.value = null - } - - return { - isPreloading: readonly(isPreloading), - isPreloaded: readonly(isPreloaded), - preloadError: readonly(preloadError), - preloadMarket, - resetPreload - } -} \ No newline at end of file +// Compatibility re-export for the moved useMarketPreloader composable +export * from '@/modules/market/composables/useMarketPreloader' +export { useMarketPreloader } from '@/modules/market/composables/useMarketPreloader' \ No newline at end of file diff --git a/src/lib/services/nostrmarketService.ts b/src/lib/services/nostrmarketService.ts index 51a6a8a..dd303eb 100644 --- a/src/lib/services/nostrmarketService.ts +++ b/src/lib/services/nostrmarketService.ts @@ -1,460 +1,3 @@ -import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' -import { relayHub } from '@/lib/nostr/relayHub' -import { auth } from '@/composables/useAuth' -import type { Stall, Product, Order } from '@/stores/market' - -export interface NostrmarketStall { - id: string - name: string - description?: string - currency: string - shipping: Array<{ - id: string - name: string - cost: number - countries: string[] - }> -} - -export interface NostrmarketProduct { - id: string - stall_id: string - name: string - description?: string - images: string[] - categories: string[] - price: number - quantity: number - currency: string -} - -export interface NostrmarketOrder { - id: string - items: Array<{ - product_id: string - quantity: number - }> - contact: { - name: string - email?: string - phone?: string - } - address?: { - street: string - city: string - state: string - country: string - postal_code: string - } - shipping_id: string -} - -export interface NostrmarketPaymentRequest { - type: 1 - id: string - message?: string - payment_options: Array<{ - type: string - link: string - }> -} - -export interface NostrmarketOrderStatus { - type: 2 - id: string - message?: string - paid?: boolean - shipped?: boolean -} - -export class NostrmarketService { - /** - * Convert hex string to Uint8Array (browser-compatible) - */ - private hexToUint8Array(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2) - for (let i = 0; i < hex.length; i += 2) { - bytes[i / 2] = parseInt(hex.substr(i, 2), 16) - } - return bytes - } - - private getAuth() { - if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) { - throw new Error('User not authenticated or private key not available') - } - - const pubkey = auth.currentUser.value.pubkey - const prvkey = auth.currentUser.value.prvkey - - if (!pubkey || !prvkey) { - throw new Error('Public key or private key is missing') - } - - // Validate that we have proper hex strings - if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) { - throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`) - } - - if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) { - throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`) - } - - console.log('🔑 Key debug:', { - pubkey: pubkey.substring(0, 10) + '...', - prvkey: prvkey.substring(0, 10) + '...', - pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey), - prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey), - pubkeyLength: pubkey.length, - prvkeyLength: prvkey.length, - pubkeyType: typeof pubkey, - prvkeyType: typeof prvkey, - pubkeyIsString: typeof pubkey === 'string', - prvkeyIsString: typeof prvkey === 'string' - }) - - return { - pubkey, - prvkey - } - } - - /** - * Publish a stall event (kind 30017) to Nostr - */ - async publishStall(stall: Stall): Promise { - const { prvkey } = this.getAuth() - - const stallData: NostrmarketStall = { - id: stall.id, - name: stall.name, - description: stall.description, - currency: stall.currency, - shipping: (stall.shipping || []).map(zone => ({ - id: zone.id, - name: zone.name, - cost: zone.cost, - countries: [] - })) - } - - const eventTemplate: EventTemplate = { - kind: 30017, - tags: [ - ['t', 'stall'], - ['t', 'nostrmarket'] - ], - content: JSON.stringify(stallData), - created_at: Math.floor(Date.now() / 1000) - } - - const prvkeyBytes = this.hexToUint8Array(prvkey) - const event = finalizeEvent(eventTemplate, prvkeyBytes) - const result = await relayHub.publishEvent(event) - - console.log('Stall published to nostrmarket:', { - stallId: stall.id, - eventId: result, - content: stallData - }) - - return result.success.toString() - } - - /** - * Publish a product event (kind 30018) to Nostr - */ - async publishProduct(product: Product): Promise { - const { prvkey } = this.getAuth() - - const productData: NostrmarketProduct = { - id: product.id, - stall_id: product.stall_id, - name: product.name, - description: product.description, - images: product.images || [], - categories: product.categories || [], - price: product.price, - quantity: product.quantity, - currency: product.currency - } - - const eventTemplate: EventTemplate = { - kind: 30018, - tags: [ - ['t', 'product'], - ['t', 'nostrmarket'], - ['t', 'stall', product.stall_id], - ...(product.categories || []).map(cat => ['t', cat]) - ], - content: JSON.stringify(productData), - created_at: Math.floor(Date.now() / 1000) - } - - const prvkeyBytes = this.hexToUint8Array(prvkey) - const event = finalizeEvent(eventTemplate, prvkeyBytes) - const result = await relayHub.publishEvent(event) - - console.log('Product published to nostrmarket:', { - productId: product.id, - eventId: result, - content: productData - }) - - return result.success.toString() - } - - /** - * Publish an order event (kind 4 encrypted DM) to nostrmarket - */ - async publishOrder(order: Order, merchantPubkey: string): Promise { - const { prvkey } = this.getAuth() - - // Convert order to nostrmarket format - exactly matching the specification - const orderData = { - type: 0, // DirectMessageType.CUSTOMER_ORDER - id: order.id, - items: order.items.map(item => ({ - product_id: item.productId, - quantity: item.quantity - })), - contact: { - name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown', - email: order.contactInfo?.email || '' - // Remove phone field - not in nostrmarket specification - }, - // Only include address if it's a physical good and address is provided - ...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? { - address: order.contactInfo.address - } : {}), - shipping_id: order.shippingZone?.id || 'online' - } - - // Encrypt the message using NIP-04 - console.log('🔐 NIP-04 encryption debug:', { - prvkeyType: typeof prvkey, - prvkeyIsString: typeof prvkey === 'string', - prvkeyLength: prvkey.length, - prvkeySample: prvkey.substring(0, 10) + '...', - merchantPubkeyType: typeof merchantPubkey, - merchantPubkeyLength: merchantPubkey.length, - orderDataString: JSON.stringify(orderData).substring(0, 50) + '...' - }) - - let encryptedContent: string - try { - encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData)) - console.log('🔐 NIP-04 encryption successful:', { - encryptedContentLength: encryptedContent.length, - encryptedContentSample: encryptedContent.substring(0, 50) + '...' - }) - } catch (error) { - console.error('🔐 NIP-04 encryption failed:', error) - throw error - } - - const eventTemplate: EventTemplate = { - kind: 4, // Encrypted DM - tags: [['p', merchantPubkey]], // Recipient (merchant) - content: encryptedContent, // Use encrypted content - created_at: Math.floor(Date.now() / 1000) - } - - console.log('🔧 finalizeEvent debug:', { - prvkeyType: typeof prvkey, - prvkeyIsString: typeof prvkey === 'string', - prvkeyLength: prvkey.length, - prvkeySample: prvkey.substring(0, 10) + '...', - encodedPrvkeyType: typeof new TextEncoder().encode(prvkey), - encodedPrvkeyLength: new TextEncoder().encode(prvkey).length, - eventTemplate - }) - - // Convert hex string to Uint8Array properly - const prvkeyBytes = this.hexToUint8Array(prvkey) - console.log('🔧 prvkeyBytes debug:', { - prvkeyBytesType: typeof prvkeyBytes, - prvkeyBytesLength: prvkeyBytes.length, - prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array - }) - - const event = finalizeEvent(eventTemplate, prvkeyBytes) - const result = await relayHub.publishEvent(event) - - console.log('Order published to nostrmarket:', { - orderId: order.id, - eventId: result, - merchantPubkey, - content: orderData, - encryptedContent: encryptedContent.substring(0, 50) + '...' - }) - - return result.success.toString() - } - - /** - * Handle incoming payment request from merchant (type 1) - */ - async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise { - console.log('Received payment request from merchant:', { - orderId: paymentRequest.id, - message: paymentRequest.message, - paymentOptions: paymentRequest.payment_options - }) - - // Find the Lightning payment option - const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln') - if (!lightningOption) { - console.error('No Lightning payment option found in payment request') - return - } - - // Update the order in the store with payment request - const { useMarketStore } = await import('@/stores/market') - const marketStore = useMarketStore() - - const order = Object.values(marketStore.orders).find(o => - o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id - ) - - if (order) { - // Update order with payment request details - const updatedOrder = { - ...order, - paymentRequest: lightningOption.link, - paymentStatus: 'pending' as const, - status: 'pending' as const, // Ensure status is pending for payment - updatedAt: Math.floor(Date.now() / 1000), - items: [...order.items] // Convert readonly to mutable - } - - // Generate QR code for the payment request - try { - const QRCode = await import('qrcode') - const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, { - width: 256, - margin: 2, - color: { - dark: '#000000', - light: '#FFFFFF' - } - }) - updatedOrder.qrCodeDataUrl = qrCodeDataUrl - updatedOrder.qrCodeLoading = false - updatedOrder.qrCodeError = null - } catch (error) { - console.error('Failed to generate QR code:', error) - updatedOrder.qrCodeError = 'Failed to generate QR code' - updatedOrder.qrCodeLoading = false - } - - marketStore.updateOrder(order.id, updatedOrder) - - console.log('Order updated with payment request:', { - orderId: paymentRequest.id, - paymentRequest: lightningOption.link.substring(0, 50) + '...', - status: updatedOrder.status, - paymentStatus: updatedOrder.paymentStatus, - hasQRCode: !!updatedOrder.qrCodeDataUrl - }) - } else { - console.warn('Payment request received for unknown order:', paymentRequest.id) - } - } - - /** - * Handle incoming order status update from merchant (type 2) - */ - async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise { - console.log('Received order status update from merchant:', { - orderId: statusUpdate.id, - message: statusUpdate.message, - paid: statusUpdate.paid, - shipped: statusUpdate.shipped - }) - - const { useMarketStore } = await import('@/stores/market') - const marketStore = useMarketStore() - - const order = Object.values(marketStore.orders).find(o => - o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id - ) - - if (order) { - // Update order status - if (statusUpdate.paid !== undefined) { - const newStatus = statusUpdate.paid ? 'paid' : 'pending' - marketStore.updateOrderStatus(order.id, newStatus) - - // Also update payment status - const updatedOrder = { - ...order, - paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired', - paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined, - updatedAt: Math.floor(Date.now() / 1000), - items: [...order.items] // Convert readonly to mutable - } - marketStore.updateOrder(order.id, updatedOrder) - } - - if (statusUpdate.shipped !== undefined) { - // Update shipping status if you have that field - const updatedOrder = { - ...order, - shipped: statusUpdate.shipped, - status: statusUpdate.shipped ? 'shipped' : order.status, - updatedAt: Math.floor(Date.now() / 1000), - items: [...order.items] // Convert readonly to mutable - } - marketStore.updateOrder(order.id, updatedOrder) - } - - console.log('Order status updated:', { - orderId: statusUpdate.id, - paid: statusUpdate.paid, - shipped: statusUpdate.shipped, - newStatus: statusUpdate.paid ? 'paid' : 'pending' - }) - } else { - console.warn('Status update received for unknown order:', statusUpdate.id) - } - } - - /** - * Publish all stalls and products for a merchant - */ - async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{ - stalls: Record, // stallId -> eventId - products: Record // productId -> eventId - }> { - const results = { - stalls: {} as Record, - products: {} as Record - } - - // Publish stalls first - for (const stall of stalls) { - try { - const eventId = await this.publishStall(stall) - results.stalls[stall.id] = eventId - } catch (error) { - console.error(`Failed to publish stall ${stall.id}:`, error) - } - } - - // Publish products - for (const product of products) { - try { - const eventId = await this.publishProduct(product) - results.products[product.id] = eventId - } catch (error) { - console.error(`Failed to publish product ${product.id}:`, error) - } - } - - return results - } -} - -// Export singleton instance -export const nostrmarketService = new NostrmarketService() +// Compatibility re-export for the moved nostrmarketService +export * from '@/modules/market/services/nostrmarketService' +export { nostrmarketService } from '@/modules/market/services/nostrmarketService' \ No newline at end of file diff --git a/src/modules/chat/components/ChatComponent.vue b/src/modules/chat/components/ChatComponent.vue new file mode 100644 index 0000000..10aa158 --- /dev/null +++ b/src/modules/chat/components/ChatComponent.vue @@ -0,0 +1,627 @@ + + + \ No newline at end of file diff --git a/src/modules/chat/composables/useChat.ts b/src/modules/chat/composables/useChat.ts new file mode 100644 index 0000000..736102e --- /dev/null +++ b/src/modules/chat/composables/useChat.ts @@ -0,0 +1,82 @@ +import { ref, computed } from 'vue' +import { injectService } from '@/core/di-container' +import type { ChatService } from '../services/chat-service' +import type { ChatPeer } from '../types' + +// Service token for chat service +export const CHAT_SERVICE_TOKEN = Symbol('chatService') + +export function useChat() { + const chatService = injectService(CHAT_SERVICE_TOKEN) + + if (!chatService) { + throw new Error('ChatService not available. Make sure chat module is installed.') + } + + const selectedPeer = ref(null) + const isLoading = ref(false) + const error = ref(null) + + // Computed properties + const peers = computed(() => chatService.allPeers.value) + const totalUnreadCount = computed(() => chatService.totalUnreadCount.value) + + const currentMessages = computed(() => { + return selectedPeer.value ? chatService.getMessages(selectedPeer.value) : [] + }) + + const currentPeer = computed(() => { + return selectedPeer.value ? chatService.getPeer(selectedPeer.value) : undefined + }) + + // Methods + const selectPeer = (peerPubkey: string) => { + selectedPeer.value = peerPubkey + chatService.markAsRead(peerPubkey) + } + + const sendMessage = async (content: string) => { + if (!selectedPeer.value || !content.trim()) { + return + } + + isLoading.value = true + error.value = null + + try { + await chatService.sendMessage(selectedPeer.value, content.trim()) + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to send message' + console.error('Send message error:', err) + } finally { + isLoading.value = false + } + } + + const addPeer = (pubkey: string, name?: string): ChatPeer => { + return chatService.addPeer(pubkey, name) + } + + const markAsRead = (peerPubkey: string) => { + chatService.markAsRead(peerPubkey) + } + + return { + // State + selectedPeer, + isLoading, + error, + + // Computed + peers, + totalUnreadCount, + currentMessages, + currentPeer, + + // Methods + selectPeer, + sendMessage, + addPeer, + markAsRead + } +} \ No newline at end of file diff --git a/src/modules/chat/index.ts b/src/modules/chat/index.ts new file mode 100644 index 0000000..1d27bec --- /dev/null +++ b/src/modules/chat/index.ts @@ -0,0 +1,106 @@ +import type { App } from 'vue' +import type { ModulePlugin } from '@/core/types' +import type { RouteRecordRaw } from 'vue-router' +import { container } from '@/core/di-container' +import { eventBus } from '@/core/event-bus' + +// Import chat components and services +import ChatComponent from './components/ChatComponent.vue' +import { ChatService } from './services/chat-service' +import { useChat, CHAT_SERVICE_TOKEN } from './composables/useChat' +import type { ChatConfig } from './types' + +/** + * Chat Module Plugin + * Provides Nostr-based encrypted chat functionality + */ +export const chatModule: ModulePlugin = { + name: 'chat', + version: '1.0.0', + dependencies: ['base'], + + async install(app: App, options?: { config?: ChatConfig }) { + console.log('💬 Installing chat module...') + + const config: ChatConfig = { + maxMessages: 500, + autoScroll: true, + showTimestamps: true, + notificationsEnabled: true, + soundEnabled: false, + ...options?.config + } + + // Create and register chat service + const chatService = new ChatService(config) + container.provide(CHAT_SERVICE_TOKEN, chatService) + + // Register global components + app.component('ChatComponent', ChatComponent) + + // Set up event listeners for integration with other modules + setupEventListeners(chatService) + + console.log('✅ Chat module installed successfully') + }, + + async uninstall() { + console.log('🗑️ Uninstalling chat module...') + + // Clean up chat service + const chatService = container.inject(CHAT_SERVICE_TOKEN) + if (chatService) { + chatService.destroy() + container.remove(CHAT_SERVICE_TOKEN) + } + + console.log('✅ Chat module uninstalled') + }, + + routes: [ + { + path: '/chat', + name: 'chat', + component: () => import('./views/ChatPage.vue'), + meta: { + title: 'Nostr Chat', + requiresAuth: true + } + } + ] as RouteRecordRaw[], + + components: { + ChatComponent + }, + + composables: { + useChat + }, + + services: { + chatService: CHAT_SERVICE_TOKEN + } +} + +// Private function to set up event listeners +function setupEventListeners(chatService: ChatService) { + // Listen for auth events to clear chat data on logout + eventBus.on('auth:logout', () => { + chatService.destroy() + }) + + // Listen for Nostr events that might be chat messages + eventBus.on('nostr:event', (event) => { + // TODO: Process incoming Nostr events for encrypted DMs + console.log('Received Nostr event in chat module:', event) + }) + + // Emit chat events for other modules to listen to + // This is already handled by the ChatService via eventBus +} + +export default chatModule + +// Re-export types and composables for external use +export type { ChatMessage, ChatPeer, ChatConfig } from './types' +export { useChat } from './composables/useChat' \ No newline at end of file diff --git a/src/modules/chat/services/chat-service.ts b/src/modules/chat/services/chat-service.ts new file mode 100644 index 0000000..b44ea6d --- /dev/null +++ b/src/modules/chat/services/chat-service.ts @@ -0,0 +1,240 @@ +import { ref, computed } from 'vue' +import { eventBus } from '@/core/event-bus' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types' + +const UNREAD_MESSAGES_KEY = 'nostr-chat-unread-messages' +const PEERS_KEY = 'nostr-chat-peers' + +export class ChatService { + private messages = ref>(new Map()) + private peers = ref>(new Map()) + private config: ChatConfig + + constructor(config: ChatConfig) { + this.config = config + this.loadPeersFromStorage() + } + + // Computed properties + get allPeers() { + return computed(() => Array.from(this.peers.value.values())) + } + + get totalUnreadCount() { + return computed(() => { + return Array.from(this.peers.value.values()) + .reduce((total, peer) => total + peer.unreadCount, 0) + }) + } + + // Get messages for a specific peer + getMessages(peerPubkey: string): ChatMessage[] { + return this.messages.value.get(peerPubkey) || [] + } + + // Get peer by pubkey + getPeer(pubkey: string): ChatPeer | undefined { + return this.peers.value.get(pubkey) + } + + // Add or update a peer + addPeer(pubkey: string, name?: string): ChatPeer { + let peer = this.peers.value.get(pubkey) + + if (!peer) { + peer = { + pubkey, + name: name || `User ${pubkey.slice(0, 8)}`, + unreadCount: 0, + lastSeen: Date.now() + } + + this.peers.value.set(pubkey, peer) + this.savePeersToStorage() + + eventBus.emit('chat:peer-added', { peer }, 'chat-service') + } else if (name && name !== peer.name) { + peer.name = name + this.savePeersToStorage() + } + + return peer + } + + // Add a message + addMessage(peerPubkey: string, message: ChatMessage): void { + if (!this.messages.value.has(peerPubkey)) { + this.messages.value.set(peerPubkey, []) + } + + const peerMessages = this.messages.value.get(peerPubkey)! + + // Avoid duplicates + if (!peerMessages.some(m => m.id === message.id)) { + peerMessages.push(message) + + // Sort by timestamp + peerMessages.sort((a, b) => a.created_at - b.created_at) + + // Limit message count + if (peerMessages.length > this.config.maxMessages) { + peerMessages.splice(0, peerMessages.length - this.config.maxMessages) + } + + // Update peer info + const peer = this.addPeer(peerPubkey) + peer.lastMessage = message + peer.lastSeen = Date.now() + + // Update unread count if message is not sent by us + if (!message.sent) { + this.updateUnreadCount(peerPubkey, message) + } + + // Emit events + const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received' + eventBus.emit(eventType, { message, peerPubkey }, 'chat-service') + } + } + + // Mark messages as read for a peer + markAsRead(peerPubkey: string): void { + const peer = this.peers.value.get(peerPubkey) + if (peer && peer.unreadCount > 0) { + peer.unreadCount = 0 + + // Save unread state + const unreadData: UnreadMessageData = { + lastReadTimestamp: Date.now(), + unreadCount: 0, + processedMessageIds: new Set() + } + this.saveUnreadData(peerPubkey, unreadData) + + eventBus.emit('chat:unread-count-changed', { + peerPubkey, + count: 0, + totalUnread: this.totalUnreadCount.value + }, 'chat-service') + } + } + + // Send a message + async sendMessage(peerPubkey: string, content: string): Promise { + try { + const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) + const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any + + if (!relayHub || !authService?.user?.value?.privkey) { + throw new Error('Required services not available') + } + + // Create message + const message: ChatMessage = { + id: crypto.randomUUID(), + content, + created_at: Math.floor(Date.now() / 1000), + sent: true, + pubkey: authService.user.value.pubkey + } + + // Add to local messages immediately + this.addMessage(peerPubkey, message) + + // TODO: Implement actual Nostr message sending + // This would involve encrypting the message and publishing to relays + console.log('Sending message:', { peerPubkey, content }) + + } catch (error) { + console.error('Failed to send message:', error) + throw error + } + } + + // Private methods + private updateUnreadCount(peerPubkey: string, message: ChatMessage): void { + const unreadData = this.getUnreadData(peerPubkey) + + if (!unreadData.processedMessageIds.has(message.id)) { + unreadData.processedMessageIds.add(message.id) + unreadData.unreadCount++ + + const peer = this.peers.value.get(peerPubkey) + if (peer) { + peer.unreadCount = unreadData.unreadCount + this.savePeersToStorage() + } + + this.saveUnreadData(peerPubkey, unreadData) + + eventBus.emit('chat:unread-count-changed', { + peerPubkey, + count: unreadData.unreadCount, + totalUnread: this.totalUnreadCount.value + }, 'chat-service') + } + } + + private getUnreadData(peerPubkey: string): UnreadMessageData { + try { + const stored = localStorage.getItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`) + if (stored) { + const data = JSON.parse(stored) + return { + ...data, + processedMessageIds: new Set(data.processedMessageIds || []) + } + } + } catch (error) { + console.warn('Failed to load unread data for peer:', peerPubkey, error) + } + + return { + lastReadTimestamp: 0, + unreadCount: 0, + processedMessageIds: new Set() + } + } + + private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void { + try { + const serializable = { + ...data, + processedMessageIds: Array.from(data.processedMessageIds) + } + localStorage.setItem(`${UNREAD_MESSAGES_KEY}-${peerPubkey}`, JSON.stringify(serializable)) + } catch (error) { + console.warn('Failed to save unread data for peer:', peerPubkey, error) + } + } + + private loadPeersFromStorage(): void { + try { + const stored = localStorage.getItem(PEERS_KEY) + if (stored) { + const peersArray = JSON.parse(stored) as ChatPeer[] + peersArray.forEach(peer => { + this.peers.value.set(peer.pubkey, peer) + }) + } + } catch (error) { + console.warn('Failed to load peers from storage:', error) + } + } + + private savePeersToStorage(): void { + try { + const peersArray = Array.from(this.peers.value.values()) + localStorage.setItem(PEERS_KEY, JSON.stringify(peersArray)) + } catch (error) { + console.warn('Failed to save peers to storage:', error) + } + } + + // Cleanup + destroy(): void { + this.messages.value.clear() + this.peers.value.clear() + } +} \ No newline at end of file diff --git a/src/modules/chat/types/index.ts b/src/modules/chat/types/index.ts new file mode 100644 index 0000000..37b23fa --- /dev/null +++ b/src/modules/chat/types/index.ts @@ -0,0 +1,57 @@ +// Chat module types + +export interface ChatMessage { + id: string + content: string + created_at: number + sent: boolean + pubkey: string +} + +export interface ChatPeer { + pubkey: string + name?: string + lastMessage?: ChatMessage + unreadCount: number + lastSeen: number +} + +export interface NostrRelayConfig { + url: string + read?: boolean + write?: boolean +} + +export interface UnreadMessageData { + lastReadTimestamp: number + unreadCount: number + processedMessageIds: Set +} + +export interface ChatConfig { + maxMessages: number + autoScroll: boolean + showTimestamps: boolean + notificationsEnabled: boolean + soundEnabled: boolean +} + +// Events emitted by chat module +export interface ChatEvents { + 'chat:message-received': { + message: ChatMessage + peerPubkey: string + } + 'chat:message-sent': { + message: ChatMessage + peerPubkey: string + } + 'chat:peer-added': { + peer: ChatPeer + } + 'chat:unread-count-changed': { + peerPubkey: string + count: number + totalUnread: number + } +} \ No newline at end of file diff --git a/src/modules/chat/views/ChatPage.vue b/src/modules/chat/views/ChatPage.vue new file mode 100644 index 0000000..4f8888b --- /dev/null +++ b/src/modules/chat/views/ChatPage.vue @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue new file mode 100644 index 0000000..2ec7657 --- /dev/null +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -0,0 +1,255 @@ + + + + \ No newline at end of file diff --git a/src/modules/events/composables/useEvents.ts b/src/modules/events/composables/useEvents.ts new file mode 100644 index 0000000..1058fcd --- /dev/null +++ b/src/modules/events/composables/useEvents.ts @@ -0,0 +1,65 @@ +import { computed } from 'vue' +import { useAsyncState } from '@vueuse/core' +import { injectService } from '@/core/di-container' +import type { Event } from '../types/event' +import type { EventsApiService } from '../services/events-api' + +// Service token for events API +export const EVENTS_API_TOKEN = Symbol('eventsApi') + +export function useEvents() { + const eventsApi = injectService(EVENTS_API_TOKEN) + + if (!eventsApi) { + throw new Error('EventsApiService not available. Make sure events module is installed.') + } + + const { state: events, isLoading, error: asyncError, execute: refresh } = useAsyncState( + () => eventsApi.fetchEvents(), + [] as Event[], + { + immediate: true, + resetOnExecute: false, + } + ) + + const error = computed(() => { + if (asyncError.value) { + return { + message: asyncError.value instanceof Error + ? asyncError.value.message + : 'An error occurred while fetching events' + } + } + return null + }) + + const sortedEvents = computed(() => { + return [...events.value].sort((a, b) => + new Date(b.time).getTime() - new Date(a.time).getTime() + ) + }) + + const upcomingEvents = computed(() => { + const now = new Date() + return sortedEvents.value.filter(event => + new Date(event.event_start_date) > now + ) + }) + + const pastEvents = computed(() => { + const now = new Date() + return sortedEvents.value.filter(event => + new Date(event.event_end_date) < now + ) + }) + + return { + events: sortedEvents, + upcomingEvents, + pastEvents, + isLoading, + error, + refresh, + } +} diff --git a/src/modules/events/composables/useTicketPurchase.ts b/src/modules/events/composables/useTicketPurchase.ts new file mode 100644 index 0000000..86474cd --- /dev/null +++ b/src/modules/events/composables/useTicketPurchase.ts @@ -0,0 +1,242 @@ +import { ref, computed, onUnmounted } from 'vue' +import { purchaseTicket, checkPaymentStatus, payInvoiceWithWallet } from '@/lib/api/events' +import { useAuth } from '@/composables/useAuth' +import { toast } from 'vue-sonner' + +export function useTicketPurchase() { + const { isAuthenticated, currentUser } = useAuth() + + // State + const isLoading = ref(false) + const error = ref(null) + const paymentHash = ref(null) + const paymentRequest = ref(null) + const qrCode = ref(null) + const isPaymentPending = ref(false) + const isPayingWithWallet = ref(false) + + // Ticket QR code state + const ticketQRCode = ref(null) + const purchasedTicketId = ref(null) + const showTicketQR = ref(false) + + // Computed properties + const canPurchase = computed(() => isAuthenticated.value && currentUser.value) + const userDisplay = computed(() => { + if (!currentUser.value) return null + return { + name: currentUser.value.username || currentUser.value.id, + shortId: currentUser.value.id.slice(0, 8) + } + }) + + const userWallets = computed(() => currentUser.value?.wallets || []) + const hasWalletWithBalance = computed(() => + userWallets.value.some((wallet: any) => wallet.balance_msat > 0) + ) + + // Generate QR code for Lightning payment + async function generateQRCode(bolt11: string) { + try { + const qrcode = await import('qrcode') + const dataUrl = await qrcode.toDataURL(bolt11, { + width: 256, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }) + qrCode.value = dataUrl + } catch (err) { + console.error('Error generating QR code:', err) + error.value = 'Failed to generate QR code' + } + } + + // Generate QR code for ticket + async function generateTicketQRCode(ticketId: string) { + try { + const qrcode = await import('qrcode') + const ticketUrl = `ticket://${ticketId}` + const dataUrl = await qrcode.toDataURL(ticketUrl, { + width: 128, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }) + ticketQRCode.value = dataUrl + return dataUrl + } catch (error) { + console.error('Error generating ticket QR code:', error) + return null + } + } + + // Pay with wallet + async function payWithWallet(paymentRequest: string) { + const walletWithBalance = userWallets.value.find((wallet: any) => wallet.balance_msat > 0) + + if (!walletWithBalance) { + throw new Error('No wallet with sufficient balance found') + } + + try { + await payInvoiceWithWallet(paymentRequest, walletWithBalance.id, walletWithBalance.adminkey) + return true + } catch (error) { + console.error('Wallet payment failed:', error) + throw error + } + } + + // Purchase ticket for event + async function purchaseTicketForEvent(eventId: string) { + if (!canPurchase.value) { + throw new Error('User must be authenticated to purchase tickets') + } + + isLoading.value = true + error.value = null + paymentHash.value = null + paymentRequest.value = null + qrCode.value = null + ticketQRCode.value = null + purchasedTicketId.value = null + showTicketQR.value = false + + try { + // Get the invoice + const invoice = await purchaseTicket(eventId) + paymentHash.value = invoice.payment_hash + paymentRequest.value = invoice.payment_request + + // Generate QR code for payment + await generateQRCode(invoice.payment_request) + + // Try to pay with wallet if available + if (hasWalletWithBalance.value) { + isPayingWithWallet.value = true + try { + await payWithWallet(invoice.payment_request) + // If wallet payment succeeds, proceed to check payment status + await startPaymentStatusCheck(eventId, invoice.payment_hash) + } catch (walletError) { + // If wallet payment fails, fall back to manual payment + console.log('Wallet payment failed, falling back to manual payment:', walletError) + isPayingWithWallet.value = false + await startPaymentStatusCheck(eventId, invoice.payment_hash) + } + } else { + // No wallet balance, proceed with manual payment + await startPaymentStatusCheck(eventId, invoice.payment_hash) + } + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to purchase ticket' + console.error('Error purchasing ticket:', err) + } finally { + isLoading.value = false + } + } + + // Start payment status check + async function startPaymentStatusCheck(eventId: string, hash: string) { + isPaymentPending.value = true + let checkInterval: number | null = null + + const checkPayment = async () => { + try { + const result = await checkPaymentStatus(eventId, hash) + + if (result.paid) { + isPaymentPending.value = false + if (checkInterval) { + clearInterval(checkInterval) + } + + // Generate ticket QR code + if (result.ticket_id) { + purchasedTicketId.value = result.ticket_id + await generateTicketQRCode(result.ticket_id) + showTicketQR.value = true + } + + toast.success('Ticket purchased successfully!') + } + } catch (err) { + console.error('Error checking payment status:', err) + } + } + + // Check immediately + await checkPayment() + + // Then check every 2 seconds + checkInterval = setInterval(checkPayment, 2000) as unknown as number + } + + // Stop payment status check + function stopPaymentStatusCheck() { + isPaymentPending.value = false + } + + // Reset payment state + function resetPaymentState() { + isLoading.value = false + error.value = null + paymentHash.value = null + paymentRequest.value = null + qrCode.value = null + isPaymentPending.value = false + isPayingWithWallet.value = false + ticketQRCode.value = null + purchasedTicketId.value = null + showTicketQR.value = false + } + + // Open Lightning wallet + function handleOpenLightningWallet() { + if (paymentRequest.value) { + window.open(`lightning:${paymentRequest.value}`, '_blank') + } + } + + // Cleanup function + function cleanup() { + stopPaymentStatusCheck() + } + + // Lifecycle + onUnmounted(() => { + cleanup() + }) + + return { + // State + isLoading, + error, + paymentHash, + paymentRequest, + qrCode, + isPaymentPending, + isPayingWithWallet, + ticketQRCode, + purchasedTicketId, + showTicketQR, + + // Computed + canPurchase, + userDisplay, + userWallets, + hasWalletWithBalance, + + // Actions + purchaseTicketForEvent, + handleOpenLightningWallet, + resetPaymentState, + cleanup, + generateTicketQRCode + } +} \ No newline at end of file diff --git a/src/modules/events/composables/useUserTickets.ts b/src/modules/events/composables/useUserTickets.ts new file mode 100644 index 0000000..e1ece0b --- /dev/null +++ b/src/modules/events/composables/useUserTickets.ts @@ -0,0 +1,123 @@ +import { computed } from 'vue' +import { useAsyncState } from '@vueuse/core' +import type { Ticket } from '@/lib/types/event' +import { fetchUserTickets } from '@/lib/api/events' +import { useAuth } from '@/composables/useAuth' + +interface GroupedTickets { + eventId: string + tickets: Ticket[] + paidCount: number + pendingCount: number + registeredCount: number +} + +export function useUserTickets() { + const { isAuthenticated, currentUser } = useAuth() + + const { state: tickets, isLoading, error: asyncError, execute: refresh } = useAsyncState( + async () => { + if (!isAuthenticated.value || !currentUser.value) { + return [] + } + return await fetchUserTickets(currentUser.value.id) + }, + [] as Ticket[], + { + immediate: false, + resetOnExecute: false, + } + ) + + const error = computed(() => { + if (asyncError.value) { + return { + message: asyncError.value instanceof Error + ? asyncError.value.message + : 'An error occurred while fetching tickets' + } + } + return null + }) + + const sortedTickets = computed(() => { + return [...tickets.value].sort((a, b) => + new Date(b.time).getTime() - new Date(a.time).getTime() + ) + }) + + const paidTickets = computed(() => { + return sortedTickets.value.filter(ticket => ticket.paid) + }) + + const pendingTickets = computed(() => { + return sortedTickets.value.filter(ticket => !ticket.paid) + }) + + const registeredTickets = computed(() => { + return sortedTickets.value.filter(ticket => ticket.registered) + }) + + const unregisteredTickets = computed(() => { + return sortedTickets.value.filter(ticket => ticket.paid && !ticket.registered) + }) + + // Group tickets by event + const groupedTickets = computed(() => { + const groups = new Map() + + sortedTickets.value.forEach(ticket => { + if (!groups.has(ticket.event)) { + groups.set(ticket.event, { + eventId: ticket.event, + tickets: [], + paidCount: 0, + pendingCount: 0, + registeredCount: 0 + }) + } + + const group = groups.get(ticket.event)! + group.tickets.push(ticket) + + if (ticket.paid) { + group.paidCount++ + } else { + group.pendingCount++ + } + + if (ticket.registered) { + group.registeredCount++ + } + }) + + // Convert to array and sort by most recent ticket in each group + return Array.from(groups.values()).sort((a, b) => { + const aLatest = Math.max(...a.tickets.map(t => new Date(t.time).getTime())) + const bLatest = Math.max(...b.tickets.map(t => new Date(t.time).getTime())) + return bLatest - aLatest + }) + }) + + // Load tickets when authenticated + const loadTickets = async () => { + if (isAuthenticated.value && currentUser.value) { + await refresh() + } + } + + return { + // State + tickets: sortedTickets, + paidTickets, + pendingTickets, + registeredTickets, + unregisteredTickets, + groupedTickets, + isLoading, + error, + + // Actions + refresh: loadTickets, + } +} \ No newline at end of file diff --git a/src/modules/events/index.ts b/src/modules/events/index.ts new file mode 100644 index 0000000..56fa69c --- /dev/null +++ b/src/modules/events/index.ts @@ -0,0 +1,116 @@ +import type { App } from 'vue' +import type { ModulePlugin } from '@/core/types' +import type { RouteRecordRaw } from 'vue-router' +import { container } from '@/core/di-container' +import { eventBus } from '@/core/event-bus' + +// Import components and services +import PurchaseTicketDialog from './components/PurchaseTicketDialog.vue' +import { EventsApiService, type EventsApiConfig } from './services/events-api' +import { useEvents, EVENTS_API_TOKEN } from './composables/useEvents' + +export interface EventsModuleConfig { + apiConfig: EventsApiConfig + ticketValidationEndpoint?: string + maxTicketsPerUser?: number +} + +/** + * Events Module Plugin + * Provides event management and ticket purchasing functionality + */ +export const eventsModule: ModulePlugin = { + name: 'events', + version: '1.0.0', + dependencies: ['base'], + + async install(app: App, options?: { config?: EventsModuleConfig }) { + console.log('🎫 Installing events module...') + + const config = options?.config + if (!config) { + throw new Error('Events module requires configuration') + } + + // Create and register events API service + const eventsApiService = new EventsApiService(config.apiConfig) + container.provide(EVENTS_API_TOKEN, eventsApiService) + + // Register global components + app.component('PurchaseTicketDialog', PurchaseTicketDialog) + + // Set up event listeners for integration with other modules + setupEventListeners() + + console.log('✅ Events module installed successfully') + }, + + async uninstall() { + console.log('🗑️ Uninstalling events module...') + + // Clean up services + container.remove(EVENTS_API_TOKEN) + + console.log('✅ Events module uninstalled') + }, + + routes: [ + { + path: '/events', + name: 'events', + component: () => import('./views/EventsPage.vue'), + meta: { + title: 'Events', + requiresAuth: true + } + }, + { + path: '/my-tickets', + name: 'my-tickets', + component: () => import('./views/MyTicketsPage.vue'), + meta: { + title: 'My Tickets', + requiresAuth: true + } + } + ] as RouteRecordRaw[], + + components: { + PurchaseTicketDialog + }, + + composables: { + useEvents + }, + + services: { + eventsApi: EVENTS_API_TOKEN + } +} + +// Set up event listeners for integration with other modules +function setupEventListeners() { + // Listen for auth events + eventBus.on('auth:logout', () => { + // Clear any cached event data if needed + console.log('Events module: user logged out, clearing cache') + }) + + // Listen for payment events from other modules + eventBus.on('payment:completed', (event) => { + console.log('Events module: payment completed', event.data) + // Could refresh events or ticket status here + }) + + // Emit events for other modules + eventBus.on('events:ticket-purchased', (event) => { + console.log('Ticket purchased:', event.data) + // Other modules can listen to this event + }) +} + +export default eventsModule + +// Re-export types and composables for external use +export type { Event, Ticket } from './types/event' +export { useEvents } from './composables/useEvents' \ No newline at end of file diff --git a/src/modules/events/services/events-api.ts b/src/modules/events/services/events-api.ts new file mode 100644 index 0000000..3c81522 --- /dev/null +++ b/src/modules/events/services/events-api.ts @@ -0,0 +1,155 @@ +// Events API service for the events module +import type { Event, Ticket } from '../types/event' + +export interface EventsApiConfig { + baseUrl: string + apiKey: string +} + +export class EventsApiService { + constructor(private config: EventsApiConfig) {} + + async fetchEvents(): Promise { + try { + const response = await fetch( + `${this.config.baseUrl}/events/api/v1/events/public`, + { + headers: { + 'accept': 'application/json', + }, + } + ) + + if (!response.ok) { + const error = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to fetch events' + throw new Error(errorMessage) + } + + return await response.json() as Event[] + } catch (error) { + console.error('Error fetching events:', error) + throw error + } + } + + async purchaseTicket(eventId: string, userId: string, accessToken: string): Promise<{ payment_hash: string; payment_request: string }> { + try { + const response = await fetch( + `${this.config.baseUrl}/events/api/v1/tickets/${eventId}/user/${userId}`, + { + method: 'GET', + headers: { + 'accept': 'application/json', + 'X-API-KEY': this.config.apiKey, + 'Authorization': `Bearer ${accessToken}`, + }, + } + ) + + if (!response.ok) { + const error = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to purchase ticket' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error purchasing ticket:', error) + throw error + } + } + + async checkPaymentStatus(eventId: string, paymentHash: string): Promise<{ paid: boolean; ticket_id?: string }> { + try { + const response = await fetch( + `${this.config.baseUrl}/events/api/v1/tickets/${eventId}/${paymentHash}`, + { + method: 'POST', + headers: { + 'accept': 'application/json', + 'X-API-KEY': this.config.apiKey, + }, + } + ) + + if (!response.ok) { + const error = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to check payment status' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error checking payment status:', error) + throw error + } + } + + async fetchUserTickets(userId: string, accessToken: string): Promise { + try { + const response = await fetch( + `${this.config.baseUrl}/events/api/v1/tickets/user/${userId}`, + { + headers: { + 'accept': 'application/json', + 'X-API-KEY': this.config.apiKey, + 'Authorization': `Bearer ${accessToken}`, + }, + } + ) + + if (!response.ok) { + const error = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to fetch user tickets' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error fetching user tickets:', error) + throw error + } + } + + async payInvoiceWithWallet(paymentRequest: string, adminKey: string): Promise<{ payment_hash: string; fee_msat: number; preimage: string }> { + try { + const response = await fetch( + `${this.config.baseUrl}/api/v1/payments`, + { + method: 'POST', + headers: { + 'accept': 'application/json', + 'Content-Type': 'application/json', + 'X-API-KEY': adminKey, + }, + body: JSON.stringify({ + out: true, + bolt11: paymentRequest, + }), + } + ) + + if (!response.ok) { + const error = await response.json() + const errorMessage = typeof error.detail === 'string' + ? error.detail + : error.detail[0]?.msg || 'Failed to pay invoice' + throw new Error(errorMessage) + } + + return await response.json() + } catch (error) { + console.error('Error paying invoice:', error) + throw error + } + } +} \ No newline at end of file diff --git a/src/modules/events/types/event.ts b/src/modules/events/types/event.ts new file mode 100644 index 0000000..65a965b --- /dev/null +++ b/src/modules/events/types/event.ts @@ -0,0 +1,36 @@ +export interface Event { + id: string + wallet: string + name: string + info: string + closing_date: string + event_start_date: string + event_end_date: string + currency: string + amount_tickets: number + price_per_ticket: number + time: string + sold: number + banner: string | null +} + +export interface Ticket { + id: string + wallet: string + event: string + name: string | null + email: string | null + user_id: string | null + registered: boolean + paid: boolean + time: string + reg_timestamp: string +} + +export interface EventsApiError { + detail: Array<{ + loc: [string, number] + msg: string + type: string + }> +} \ No newline at end of file diff --git a/src/modules/events/views/EventsPage.vue b/src/modules/events/views/EventsPage.vue new file mode 100644 index 0000000..51f8e07 --- /dev/null +++ b/src/modules/events/views/EventsPage.vue @@ -0,0 +1,168 @@ + + + + diff --git a/src/modules/events/views/MyTicketsPage.vue b/src/modules/events/views/MyTicketsPage.vue new file mode 100644 index 0000000..6ec31e2 --- /dev/null +++ b/src/modules/events/views/MyTicketsPage.vue @@ -0,0 +1,599 @@ + + + + \ No newline at end of file diff --git a/src/components/market/CartItem.vue b/src/modules/market/components/CartItem.vue similarity index 99% rename from src/components/market/CartItem.vue rename to src/modules/market/components/CartItem.vue index 88e461d..36157ac 100644 --- a/src/components/market/CartItem.vue +++ b/src/modules/market/components/CartItem.vue @@ -200,7 +200,7 @@ import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Minus, Plus, Trash2 } from 'lucide-vue-next' -import type { CartItem as CartItemType } from '@/stores/market' +import type { CartItem as CartItemType } from '../types/market' interface Props { item: CartItemType diff --git a/src/modules/market/components/CartSummary.vue b/src/modules/market/components/CartSummary.vue new file mode 100644 index 0000000..abb9d7d --- /dev/null +++ b/src/modules/market/components/CartSummary.vue @@ -0,0 +1,231 @@ + + + diff --git a/src/components/market/DashboardOverview.vue b/src/modules/market/components/DashboardOverview.vue similarity index 100% rename from src/components/market/DashboardOverview.vue rename to src/modules/market/components/DashboardOverview.vue diff --git a/src/components/market/MarketSettings.vue b/src/modules/market/components/MarketSettings.vue similarity index 100% rename from src/components/market/MarketSettings.vue rename to src/modules/market/components/MarketSettings.vue diff --git a/src/components/market/MerchantStore.vue b/src/modules/market/components/MerchantStore.vue similarity index 100% rename from src/components/market/MerchantStore.vue rename to src/modules/market/components/MerchantStore.vue diff --git a/src/components/market/OrderHistory.vue b/src/modules/market/components/OrderHistory.vue similarity index 100% rename from src/components/market/OrderHistory.vue rename to src/modules/market/components/OrderHistory.vue diff --git a/src/modules/market/components/PaymentDisplay.vue b/src/modules/market/components/PaymentDisplay.vue new file mode 100644 index 0000000..01187f1 --- /dev/null +++ b/src/modules/market/components/PaymentDisplay.vue @@ -0,0 +1,334 @@ + + + diff --git a/src/components/market/PaymentRequestDialog.vue b/src/modules/market/components/PaymentRequestDialog.vue similarity index 100% rename from src/components/market/PaymentRequestDialog.vue rename to src/modules/market/components/PaymentRequestDialog.vue diff --git a/src/components/market/ProductCard.vue b/src/modules/market/components/ProductCard.vue similarity index 100% rename from src/components/market/ProductCard.vue rename to src/modules/market/components/ProductCard.vue diff --git a/src/modules/market/components/ShoppingCart.vue b/src/modules/market/components/ShoppingCart.vue new file mode 100644 index 0000000..05ae898 --- /dev/null +++ b/src/modules/market/components/ShoppingCart.vue @@ -0,0 +1,250 @@ + + + diff --git a/src/modules/market/composables/index.ts b/src/modules/market/composables/index.ts new file mode 100644 index 0000000..4979c48 --- /dev/null +++ b/src/modules/market/composables/index.ts @@ -0,0 +1,2 @@ +export { useMarket } from './useMarket' +export { useMarketPreloader } from './useMarketPreloader' \ No newline at end of file diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts new file mode 100644 index 0000000..bd81994 --- /dev/null +++ b/src/modules/market/composables/useMarket.ts @@ -0,0 +1,538 @@ +import { ref, computed, onMounted, onUnmounted, readonly } from 'vue' +import { useNostrStore } from '@/stores/nostr' +import { useMarketStore } from '../stores/market' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +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 relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any + + if (!relayHub) { + throw new Error('RelayHub not available. Make sure base module is installed.') + } + + // State + const isLoading = ref(false) + const error = ref(null) + const isConnected = ref(false) + const activeMarket = computed(() => marketStore.activeMarket) + const markets = computed(() => marketStore.markets) + const stalls = computed(() => marketStore.stalls) + const products = computed(() => marketStore.products) + const orders = computed(() => marketStore.orders) + + // Connection state + const connectionStatus = computed(() => { + if (isConnected.value) return 'connected' + if (nostrStore.isConnecting) return 'connecting' + if (nostrStore.error) return 'error' + return 'disconnected' + }) + + // Load market from naddr + const loadMarket = async (naddr: string) => { + try { + isLoading.value = true + error.value = null + + // Load market from naddr + + // Parse naddr to get market data + const marketData = { + identifier: naddr.split(':')[2] || 'default', + pubkey: naddr.split(':')[1] || nostrStore.account?.pubkey || '' + } + + if (!marketData.pubkey) { + throw new Error('No pubkey available for market') + } + + await loadMarketData(marketData) + + } catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to load market') + throw err + } finally { + isLoading.value = false + } + } + + // Load market data from Nostr events + const loadMarketData = async (marketData: any) => { + try { + // Load market data from Nostr events + + // Fetch market configuration event + const events = await relayHub.queryEvents([ + { + kinds: [MARKET_EVENT_KINDS.MARKET], + authors: [marketData.pubkey], + '#d': [marketData.identifier] + } + ]) + + // Process market events + + if (events.length > 0) { + const marketEvent = events[0] + // Process market event + + const market = { + d: marketData.identifier, + pubkey: marketData.pubkey, + relays: config.market.supportedRelays, + selected: true, + opts: JSON.parse(marketEvent.content) + } + + marketStore.addMarket(market) + marketStore.setActiveMarket(market) + } else { + // No market events found, create default + // Create a default market if none exists + const market = { + d: marketData.identifier, + pubkey: marketData.pubkey, + relays: config.market.supportedRelays, + selected: true, + opts: { + name: 'Ariège Market', + description: 'A communal market to sell your goods', + merchants: [], + ui: {} + } + } + + marketStore.addMarket(market) + marketStore.setActiveMarket(market) + } + + } catch (err) { + // Don't throw error, create default market instead + const market = { + d: marketData.identifier, + pubkey: marketData.pubkey, + relays: config.market.supportedRelays, + selected: true, + opts: { + name: 'Default Market', + description: 'A default market', + merchants: [], + ui: {} + } + } + + marketStore.addMarket(market) + marketStore.setActiveMarket(market) + } + } + + // Load stalls from market merchants + const loadStalls = async () => { + try { + // Get the active market to filter by its merchants + const activeMarket = marketStore.activeMarket + if (!activeMarket) { + return + } + + const merchants = [...(activeMarket.opts.merchants || [])] + + if (merchants.length === 0) { + return + } + + // Fetch stall events from market merchants only + const events = await relayHub.queryEvents([ + { + kinds: [MARKET_EVENT_KINDS.STALL], + authors: merchants + } + ]) + + // Process stall events + + // Group events by stall ID and keep only the most recent version + const stallGroups = new Map() + events.forEach((event: any) => { + const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] + if (stallId) { + if (!stallGroups.has(stallId)) { + stallGroups.set(stallId, []) + } + stallGroups.get(stallId)!.push(event) + } + }) + + // Process each stall group + stallGroups.forEach((stallEvents, stallId) => { + // Sort by created_at and take the most recent + const latestEvent = stallEvents.sort((a: any, b: any) => b.created_at - a.created_at)[0] + + try { + const stallData = JSON.parse(latestEvent.content) + const stall = { + id: stallId, + pubkey: latestEvent.pubkey, + name: stallData.name || 'Unnamed Stall', + description: stallData.description || '', + created_at: latestEvent.created_at, + ...stallData + } + + marketStore.addStall(stall) + } catch (err) { + // Silently handle parse errors + } + }) + + } catch (err) { + // Silently handle stall loading errors + } + } + + // Load products from market stalls + const loadProducts = async () => { + try { + const activeMarket = marketStore.activeMarket + if (!activeMarket) { + return + } + + const merchants = [...(activeMarket.opts.merchants || [])] + if (merchants.length === 0) { + return + } + + // Fetch product events from market merchants + const events = await relayHub.queryEvents([ + { + kinds: [MARKET_EVENT_KINDS.PRODUCT], + authors: merchants + } + ]) + + // Process product events + + // Group events by product ID and keep only the most recent version + const productGroups = new Map() + events.forEach((event: any) => { + const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] + if (productId) { + if (!productGroups.has(productId)) { + productGroups.set(productId, []) + } + productGroups.get(productId)!.push(event) + } + }) + + // Process each product group + 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 product = { + id: productId, + stall_id: productData.stall_id || 'unknown', + stallName: productData.stallName || 'Unknown Stall', + 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 || [], + createdAt: latestEvent.created_at, + updatedAt: latestEvent.created_at + } + + marketStore.addProduct(product) + } catch (err) { + // Silently handle parse errors + } + }) + + } catch (err) { + // Silently handle product loading errors + } + } + + // Add sample products for testing + const addSampleProducts = () => { + const sampleProducts = [ + { + id: 'sample-1', + stall_id: 'sample-stall', + stallName: 'Sample Stall', + pubkey: nostrStore.account?.pubkey || '', + name: 'Sample Product 1', + description: 'This is a sample product for testing', + price: 1000, + currency: 'sats', + quantity: 1, + images: [], + categories: [], + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000) + }, + { + id: 'sample-2', + stall_id: 'sample-stall', + stallName: 'Sample Stall', + pubkey: nostrStore.account?.pubkey || '', + name: 'Sample Product 2', + description: 'Another sample product for testing', + price: 2000, + currency: 'sats', + quantity: 1, + images: [], + categories: [], + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000) + } + ] + + sampleProducts.forEach(product => { + marketStore.addProduct(product) + }) + } + + // Subscribe to market updates + const subscribeToMarketUpdates = (): (() => void) | null => { + try { + const activeMarket = marketStore.activeMarket + if (!activeMarket) { + return null + } + + // Subscribe to market events + const unsubscribe = relayHub.subscribe({ + id: `market-${activeMarket.d}`, + filters: [ + { kinds: [MARKET_EVENT_KINDS.MARKET] }, + { kinds: [MARKET_EVENT_KINDS.STALL] }, + { kinds: [MARKET_EVENT_KINDS.PRODUCT] }, + { kinds: [MARKET_EVENT_KINDS.ORDER] } + ], + onEvent: (event: any) => { + handleMarketEvent(event) + } + }) + + return unsubscribe + } catch (error) { + return null + } + } + + // Handle incoming market events + const handleMarketEvent = (event: any) => { + // Process market event + + switch (event.kind) { + case MARKET_EVENT_KINDS.MARKET: + // Handle market updates + break + case MARKET_EVENT_KINDS.STALL: + // Handle stall updates + handleStallEvent(event) + break + case MARKET_EVENT_KINDS.PRODUCT: + // Handle product updates + handleProductEvent(event) + break + case MARKET_EVENT_KINDS.ORDER: + // Handle order updates + handleOrderEvent(event) + break + } + } + + // Process pending products (products without stalls) + const processPendingProducts = () => { + const productsWithoutStalls = products.value.filter(product => { + // Check if product has a stall tag + return !product.stall_id + }) + + if (productsWithoutStalls.length > 0) { + // You could create default stalls or handle this as needed + } + } + + // Handle stall events + const handleStallEvent = (event: any) => { + try { + const stallId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] + if (stallId) { + const stallData = JSON.parse(event.content) + const stall = { + id: stallId, + pubkey: event.pubkey, + name: stallData.name || 'Unnamed Stall', + description: stallData.description || '', + created_at: event.created_at, + ...stallData + } + + marketStore.addStall(stall) + } + } catch (err) { + // Silently handle stall event errors + } + } + + // Handle product events + const handleProductEvent = (event: any) => { + try { + const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1] + if (productId) { + const productData = JSON.parse(event.content) + const product = { + id: productId, + stall_id: productData.stall_id || 'unknown', + stallName: productData.stallName || 'Unknown Stall', + pubkey: event.pubkey, + 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 || [], + createdAt: event.created_at, + updatedAt: event.created_at + } + + marketStore.addProduct(product) + } + } catch (err) { + // Silently handle product event errors + } + } + + // Handle order events + const handleOrderEvent = (_event: any) => { + try { + // const orderData = JSON.parse(event.content) + // const order = { + // id: event.id, + // stall_id: orderData.stall_id || 'unknown', + // product_id: orderData.product_id || 'unknown', + // buyer_pubkey: event.pubkey, + // seller_pubkey: orderData.seller_pubkey || '', + // quantity: orderData.quantity || 1, + // total_price: orderData.total_price || 0, + // currency: orderData.currency || 'sats', + // status: orderData.status || 'pending', + // payment_request: orderData.payment_request, + // created_at: event.created_at, + // updated_at: event.created_at + // } + + // Note: addOrder method doesn't exist in the store, so we'll just handle it silently + } catch (err) { + // Silently handle order event errors + } + } + + // Publish a product + const publishProduct = async (_productData: any) => { + // Implementation would depend on your event creation logic + // TODO: Implement product publishing + } + + // Publish a stall + const publishStall = async (_stallData: any) => { + // Implementation would depend on your event creation logic + // TODO: Implement stall publishing + } + + // Connect to market + const connectToMarket = async () => { + try { + // Connect to market + + // Connect to relay hub + await relayHub.connect() + isConnected.value = relayHub.isConnected.value + + if (!isConnected.value) { + throw new Error('Failed to connect to Nostr relays') + } + + // Market connected successfully + + // Load market data + await loadMarketData({ + identifier: 'default', + pubkey: nostrStore.account?.pubkey || '' + }) + + // Load stalls and products + await loadStalls() + await loadProducts() + + // Subscribe to updates + subscribeToMarketUpdates() + + } catch (err) { + error.value = err instanceof Error ? err : new Error('Failed to connect to market') + throw err + } + } + + // Disconnect from market + const disconnectFromMarket = () => { + isConnected.value = false + error.value = null + // Market disconnected + } + + // Initialize market on mount + onMounted(async () => { + if (nostrStore.isConnected) { + await connectToMarket() + } + }) + + // Cleanup on unmount + onUnmounted(() => { + disconnectFromMarket() + }) + + return { + // State + isLoading: readonly(isLoading), + error: readonly(error), + isConnected: readonly(isConnected), + connectionStatus: readonly(connectionStatus), + activeMarket: readonly(activeMarket), + markets: readonly(markets), + stalls: readonly(stalls), + products: readonly(products), + orders: readonly(orders), + + // Actions + loadMarket, + connectToMarket, + disconnectFromMarket, + addSampleProducts, + processPendingProducts, + publishProduct, + publishStall, + subscribeToMarketUpdates + } +} diff --git a/src/modules/market/composables/useMarketPreloader.ts b/src/modules/market/composables/useMarketPreloader.ts new file mode 100644 index 0000000..a09ba9f --- /dev/null +++ b/src/modules/market/composables/useMarketPreloader.ts @@ -0,0 +1,60 @@ +import { ref, readonly } from 'vue' +import { useMarket } from './useMarket' +import { useMarketStore } from '../stores/market' +import { config } from '@/lib/config' + +export function useMarketPreloader() { + const isPreloading = ref(false) + const isPreloaded = ref(false) + const preloadError = ref(null) + + const market = useMarket() + const marketStore = useMarketStore() + + const preloadMarket = async () => { + // Don't preload if already done or currently preloading + if (isPreloaded.value || isPreloading.value) { + return + } + + try { + isPreloading.value = true + preloadError.value = null + + const naddr = config.market.defaultNaddr + if (!naddr) { + return + } + + // Connect to market + await market.connectToMarket() + + // Load market data + await market.loadMarket(naddr) + + // Clear any error state since preloading succeeded + marketStore.setError(null) + + isPreloaded.value = true + + } catch (error) { + preloadError.value = error instanceof Error ? error.message : 'Failed to preload market' + // Don't throw error, let the UI handle it gracefully + } finally { + isPreloading.value = false + } + } + + const resetPreload = () => { + isPreloaded.value = false + preloadError.value = null + } + + return { + isPreloading: readonly(isPreloading), + isPreloaded: readonly(isPreloaded), + preloadError: readonly(preloadError), + preloadMarket, + resetPreload + } +} \ No newline at end of file diff --git a/src/modules/market/index.ts b/src/modules/market/index.ts new file mode 100644 index 0000000..5ca8d28 --- /dev/null +++ b/src/modules/market/index.ts @@ -0,0 +1,143 @@ +import type { App } from 'vue' +import type { ModulePlugin } from '@/core/types' +import type { RouteRecordRaw } from 'vue-router' +import { container } from '@/core/di-container' +import { eventBus } from '@/core/event-bus' + +// Import components +import MarketSettings from './components/MarketSettings.vue' +import MerchantStore from './components/MerchantStore.vue' +import ShoppingCart from './components/ShoppingCart.vue' + +// Import services +import { NostrmarketService } from './services/nostrmarketService' + +// Store will be imported when needed + +// Import composables +import { useMarket } from './composables/useMarket' +import { useMarketPreloader } from './composables/useMarketPreloader' + +// Define service tokens +export const MARKET_SERVICE_TOKEN = Symbol('marketService') +export const NOSTRMARKET_SERVICE_TOKEN = Symbol('nostrmarketService') + +export interface MarketModuleConfig { + defaultCurrency: string + paymentTimeout: number + maxOrderHistory: number + supportedRelays?: string[] +} + +/** + * Market Module Plugin + * Provides market, stall, and product management functionality + */ +export const marketModule: ModulePlugin = { + name: 'market', + version: '1.0.0', + dependencies: ['base'], + + async install(app: App, options?: { config?: MarketModuleConfig }) { + console.log('🛒 Installing market module...') + + const config = options?.config + if (!config) { + throw new Error('Market module requires configuration') + } + + // Create and register services + const nostrmarketService = new NostrmarketService() + container.provide(NOSTRMARKET_SERVICE_TOKEN, nostrmarketService) + + // Register global components + app.component('MarketSettings', MarketSettings) + app.component('MerchantStore', MerchantStore) + app.component('ShoppingCart', ShoppingCart) + + // Market store will be initialized when first used + + // Set up event listeners for integration with other modules + setupEventListeners() + + console.log('✅ Market module installed successfully') + }, + + async uninstall() { + console.log('🗑️ Uninstalling market module...') + + // Clean up services + container.remove(NOSTRMARKET_SERVICE_TOKEN) + + console.log('✅ Market module uninstalled') + }, + + routes: [ + { + path: '/market', + name: 'market', + component: () => import('./views/MarketPage.vue'), + meta: { + title: 'Market', + requiresAuth: false + } + }, + { + path: '/market/dashboard', + name: 'market-dashboard', + component: () => import('./views/MarketDashboard.vue'), + meta: { + title: 'Market Dashboard', + requiresAuth: true + } + } + ] as RouteRecordRaw[], + + components: { + MarketSettings, + MerchantStore, + ShoppingCart + }, + + composables: { + useMarket, + useMarketPreloader + }, + + services: { + nostrmarket: NOSTRMARKET_SERVICE_TOKEN + } +} + +// Set up event listeners for integration with other modules +function setupEventListeners() { + // Listen for auth events + eventBus.on('auth:logout', () => { + console.log('Market module: user logged out, clearing market data') + // Could clear market-specific user data here + }) + + // Listen for payment events from other modules + eventBus.on('payment:completed', (event) => { + console.log('Market module: payment completed', event.data) + // Could update order status or refresh market data here + }) + + // Emit market-specific events + eventBus.on('market:order-placed', (event) => { + console.log('Market order placed:', event.data) + // Other modules can listen to this event + }) + + eventBus.on('market:product-added', (event) => { + console.log('Market product added:', event.data) + // Other modules can listen to this event + }) +} + +export default marketModule + +// Re-export types and composables for external use +export type * from './types/market' +export { useMarket, useMarketPreloader } from './composables' +export { useMarketStore } from './stores/market' diff --git a/src/modules/market/services/nostrmarketService.ts b/src/modules/market/services/nostrmarketService.ts new file mode 100644 index 0000000..51a6a8a --- /dev/null +++ b/src/modules/market/services/nostrmarketService.ts @@ -0,0 +1,460 @@ +import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' +import { relayHub } from '@/lib/nostr/relayHub' +import { auth } from '@/composables/useAuth' +import type { Stall, Product, Order } from '@/stores/market' + +export interface NostrmarketStall { + id: string + name: string + description?: string + currency: string + shipping: Array<{ + id: string + name: string + cost: number + countries: string[] + }> +} + +export interface NostrmarketProduct { + id: string + stall_id: string + name: string + description?: string + images: string[] + categories: string[] + price: number + quantity: number + currency: string +} + +export interface NostrmarketOrder { + id: string + items: Array<{ + product_id: string + quantity: number + }> + contact: { + name: string + email?: string + phone?: string + } + address?: { + street: string + city: string + state: string + country: string + postal_code: string + } + shipping_id: string +} + +export interface NostrmarketPaymentRequest { + type: 1 + id: string + message?: string + payment_options: Array<{ + type: string + link: string + }> +} + +export interface NostrmarketOrderStatus { + type: 2 + id: string + message?: string + paid?: boolean + shipped?: boolean +} + +export class NostrmarketService { + /** + * Convert hex string to Uint8Array (browser-compatible) + */ + private hexToUint8Array(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substr(i, 2), 16) + } + return bytes + } + + private getAuth() { + if (!auth.isAuthenticated.value || !auth.currentUser.value?.prvkey) { + throw new Error('User not authenticated or private key not available') + } + + const pubkey = auth.currentUser.value.pubkey + const prvkey = auth.currentUser.value.prvkey + + if (!pubkey || !prvkey) { + throw new Error('Public key or private key is missing') + } + + // Validate that we have proper hex strings + if (!/^[0-9a-fA-F]{64}$/.test(pubkey)) { + throw new Error(`Invalid public key format: ${pubkey.substring(0, 10)}...`) + } + + if (!/^[0-9a-fA-F]{64}$/.test(prvkey)) { + throw new Error(`Invalid private key format: ${prvkey.substring(0, 10)}...`) + } + + console.log('🔑 Key debug:', { + pubkey: pubkey.substring(0, 10) + '...', + prvkey: prvkey.substring(0, 10) + '...', + pubkeyIsHex: /^[0-9a-fA-F]{64}$/.test(pubkey), + prvkeyIsHex: /^[0-9a-fA-F]{64}$/.test(prvkey), + pubkeyLength: pubkey.length, + prvkeyLength: prvkey.length, + pubkeyType: typeof pubkey, + prvkeyType: typeof prvkey, + pubkeyIsString: typeof pubkey === 'string', + prvkeyIsString: typeof prvkey === 'string' + }) + + return { + pubkey, + prvkey + } + } + + /** + * Publish a stall event (kind 30017) to Nostr + */ + async publishStall(stall: Stall): Promise { + const { prvkey } = this.getAuth() + + const stallData: NostrmarketStall = { + id: stall.id, + name: stall.name, + description: stall.description, + currency: stall.currency, + shipping: (stall.shipping || []).map(zone => ({ + id: zone.id, + name: zone.name, + cost: zone.cost, + countries: [] + })) + } + + const eventTemplate: EventTemplate = { + kind: 30017, + tags: [ + ['t', 'stall'], + ['t', 'nostrmarket'] + ], + content: JSON.stringify(stallData), + created_at: Math.floor(Date.now() / 1000) + } + + const prvkeyBytes = this.hexToUint8Array(prvkey) + const event = finalizeEvent(eventTemplate, prvkeyBytes) + const result = await relayHub.publishEvent(event) + + console.log('Stall published to nostrmarket:', { + stallId: stall.id, + eventId: result, + content: stallData + }) + + return result.success.toString() + } + + /** + * Publish a product event (kind 30018) to Nostr + */ + async publishProduct(product: Product): Promise { + const { prvkey } = this.getAuth() + + const productData: NostrmarketProduct = { + id: product.id, + stall_id: product.stall_id, + name: product.name, + description: product.description, + images: product.images || [], + categories: product.categories || [], + price: product.price, + quantity: product.quantity, + currency: product.currency + } + + const eventTemplate: EventTemplate = { + kind: 30018, + tags: [ + ['t', 'product'], + ['t', 'nostrmarket'], + ['t', 'stall', product.stall_id], + ...(product.categories || []).map(cat => ['t', cat]) + ], + content: JSON.stringify(productData), + created_at: Math.floor(Date.now() / 1000) + } + + const prvkeyBytes = this.hexToUint8Array(prvkey) + const event = finalizeEvent(eventTemplate, prvkeyBytes) + const result = await relayHub.publishEvent(event) + + console.log('Product published to nostrmarket:', { + productId: product.id, + eventId: result, + content: productData + }) + + return result.success.toString() + } + + /** + * Publish an order event (kind 4 encrypted DM) to nostrmarket + */ + async publishOrder(order: Order, merchantPubkey: string): Promise { + const { prvkey } = this.getAuth() + + // Convert order to nostrmarket format - exactly matching the specification + const orderData = { + type: 0, // DirectMessageType.CUSTOMER_ORDER + id: order.id, + items: order.items.map(item => ({ + product_id: item.productId, + quantity: item.quantity + })), + contact: { + name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown', + email: order.contactInfo?.email || '' + // Remove phone field - not in nostrmarket specification + }, + // Only include address if it's a physical good and address is provided + ...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? { + address: order.contactInfo.address + } : {}), + shipping_id: order.shippingZone?.id || 'online' + } + + // Encrypt the message using NIP-04 + console.log('🔐 NIP-04 encryption debug:', { + prvkeyType: typeof prvkey, + prvkeyIsString: typeof prvkey === 'string', + prvkeyLength: prvkey.length, + prvkeySample: prvkey.substring(0, 10) + '...', + merchantPubkeyType: typeof merchantPubkey, + merchantPubkeyLength: merchantPubkey.length, + orderDataString: JSON.stringify(orderData).substring(0, 50) + '...' + }) + + let encryptedContent: string + try { + encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData)) + console.log('🔐 NIP-04 encryption successful:', { + encryptedContentLength: encryptedContent.length, + encryptedContentSample: encryptedContent.substring(0, 50) + '...' + }) + } catch (error) { + console.error('🔐 NIP-04 encryption failed:', error) + throw error + } + + const eventTemplate: EventTemplate = { + kind: 4, // Encrypted DM + tags: [['p', merchantPubkey]], // Recipient (merchant) + content: encryptedContent, // Use encrypted content + created_at: Math.floor(Date.now() / 1000) + } + + console.log('🔧 finalizeEvent debug:', { + prvkeyType: typeof prvkey, + prvkeyIsString: typeof prvkey === 'string', + prvkeyLength: prvkey.length, + prvkeySample: prvkey.substring(0, 10) + '...', + encodedPrvkeyType: typeof new TextEncoder().encode(prvkey), + encodedPrvkeyLength: new TextEncoder().encode(prvkey).length, + eventTemplate + }) + + // Convert hex string to Uint8Array properly + const prvkeyBytes = this.hexToUint8Array(prvkey) + console.log('🔧 prvkeyBytes debug:', { + prvkeyBytesType: typeof prvkeyBytes, + prvkeyBytesLength: prvkeyBytes.length, + prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array + }) + + const event = finalizeEvent(eventTemplate, prvkeyBytes) + const result = await relayHub.publishEvent(event) + + console.log('Order published to nostrmarket:', { + orderId: order.id, + eventId: result, + merchantPubkey, + content: orderData, + encryptedContent: encryptedContent.substring(0, 50) + '...' + }) + + return result.success.toString() + } + + /** + * Handle incoming payment request from merchant (type 1) + */ + async handlePaymentRequest(paymentRequest: NostrmarketPaymentRequest): Promise { + console.log('Received payment request from merchant:', { + orderId: paymentRequest.id, + message: paymentRequest.message, + paymentOptions: paymentRequest.payment_options + }) + + // Find the Lightning payment option + const lightningOption = paymentRequest.payment_options.find(option => option.type === 'ln') + if (!lightningOption) { + console.error('No Lightning payment option found in payment request') + return + } + + // Update the order in the store with payment request + const { useMarketStore } = await import('@/stores/market') + const marketStore = useMarketStore() + + const order = Object.values(marketStore.orders).find(o => + o.id === paymentRequest.id || o.originalOrderId === paymentRequest.id + ) + + if (order) { + // Update order with payment request details + const updatedOrder = { + ...order, + paymentRequest: lightningOption.link, + paymentStatus: 'pending' as const, + status: 'pending' as const, // Ensure status is pending for payment + updatedAt: Math.floor(Date.now() / 1000), + items: [...order.items] // Convert readonly to mutable + } + + // Generate QR code for the payment request + try { + const QRCode = await import('qrcode') + const qrCodeDataUrl = await QRCode.toDataURL(lightningOption.link, { + width: 256, + margin: 2, + color: { + dark: '#000000', + light: '#FFFFFF' + } + }) + updatedOrder.qrCodeDataUrl = qrCodeDataUrl + updatedOrder.qrCodeLoading = false + updatedOrder.qrCodeError = null + } catch (error) { + console.error('Failed to generate QR code:', error) + updatedOrder.qrCodeError = 'Failed to generate QR code' + updatedOrder.qrCodeLoading = false + } + + marketStore.updateOrder(order.id, updatedOrder) + + console.log('Order updated with payment request:', { + orderId: paymentRequest.id, + paymentRequest: lightningOption.link.substring(0, 50) + '...', + status: updatedOrder.status, + paymentStatus: updatedOrder.paymentStatus, + hasQRCode: !!updatedOrder.qrCodeDataUrl + }) + } else { + console.warn('Payment request received for unknown order:', paymentRequest.id) + } + } + + /** + * Handle incoming order status update from merchant (type 2) + */ + async handleOrderStatusUpdate(statusUpdate: NostrmarketOrderStatus): Promise { + console.log('Received order status update from merchant:', { + orderId: statusUpdate.id, + message: statusUpdate.message, + paid: statusUpdate.paid, + shipped: statusUpdate.shipped + }) + + const { useMarketStore } = await import('@/stores/market') + const marketStore = useMarketStore() + + const order = Object.values(marketStore.orders).find(o => + o.id === statusUpdate.id || o.originalOrderId === statusUpdate.id + ) + + if (order) { + // Update order status + if (statusUpdate.paid !== undefined) { + const newStatus = statusUpdate.paid ? 'paid' : 'pending' + marketStore.updateOrderStatus(order.id, newStatus) + + // Also update payment status + const updatedOrder = { + ...order, + paymentStatus: (statusUpdate.paid ? 'paid' : 'pending') as 'paid' | 'pending' | 'expired', + paidAt: statusUpdate.paid ? Math.floor(Date.now() / 1000) : undefined, + updatedAt: Math.floor(Date.now() / 1000), + items: [...order.items] // Convert readonly to mutable + } + marketStore.updateOrder(order.id, updatedOrder) + } + + if (statusUpdate.shipped !== undefined) { + // Update shipping status if you have that field + const updatedOrder = { + ...order, + shipped: statusUpdate.shipped, + status: statusUpdate.shipped ? 'shipped' : order.status, + updatedAt: Math.floor(Date.now() / 1000), + items: [...order.items] // Convert readonly to mutable + } + marketStore.updateOrder(order.id, updatedOrder) + } + + console.log('Order status updated:', { + orderId: statusUpdate.id, + paid: statusUpdate.paid, + shipped: statusUpdate.shipped, + newStatus: statusUpdate.paid ? 'paid' : 'pending' + }) + } else { + console.warn('Status update received for unknown order:', statusUpdate.id) + } + } + + /** + * Publish all stalls and products for a merchant + */ + async publishMerchantCatalog(stalls: Stall[], products: Product[]): Promise<{ + stalls: Record, // stallId -> eventId + products: Record // productId -> eventId + }> { + const results = { + stalls: {} as Record, + products: {} as Record + } + + // Publish stalls first + for (const stall of stalls) { + try { + const eventId = await this.publishStall(stall) + results.stalls[stall.id] = eventId + } catch (error) { + console.error(`Failed to publish stall ${stall.id}:`, error) + } + } + + // Publish products + for (const product of products) { + try { + const eventId = await this.publishProduct(product) + results.products[product.id] = eventId + } catch (error) { + console.error(`Failed to publish product ${product.id}:`, error) + } + } + + return results + } +} + +// Export singleton instance +export const nostrmarketService = new NostrmarketService() diff --git a/src/modules/market/stores/market.ts b/src/modules/market/stores/market.ts new file mode 100644 index 0000000..ee57713 --- /dev/null +++ b/src/modules/market/stores/market.ts @@ -0,0 +1,884 @@ +import { defineStore } from 'pinia' +import { ref, computed, readonly, watch } from 'vue' +import { nostrOrders } from '@/composables/useNostrOrders' +import { invoiceService } from '@/lib/services/invoiceService' +import { paymentMonitor } from '@/lib/services/paymentMonitor' +import { nostrmarketService } from '../services/nostrmarketService' +import { useAuth } from '@/composables/useAuth' +import type { LightningInvoice } from '@/lib/services/invoiceService' + + +import type { + Market, Stall, Product, Order, ShippingZone, + OrderStatus, StallCart, FilterData, SortOptions, + PaymentRequest, PaymentStatus +} from '../types/market' +// Import types that are used in the store implementation + +export const useMarketStore = defineStore('market', () => { + const auth = useAuth() + + // Helper function to get user-specific storage key + const getUserStorageKey = (baseKey: string) => { + const userPubkey = auth.currentUser?.value?.pubkey + return userPubkey ? `${baseKey}_${userPubkey}` : baseKey + } + // 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 error = ref(null) + const searchText = ref('') + const showFilterDetails = ref(false) + + // Filtering and sorting + const filterData = ref({ + categories: [], + merchants: [], + stalls: [], + currency: null, + priceFrom: null, + priceTo: null, + inStock: null, + paymentMethods: [] + }) + + const sortOptions = ref({ + field: 'name', + order: 'asc' + }) + + // Enhanced shopping cart with stall-specific carts + const stallCarts = ref>({}) + + // Legacy shopping cart (to be deprecated) + const shoppingCart = ref>({}) + + // Checkout state + const checkoutCart = ref(null) + const checkoutStall = ref(null) + const activeOrder = ref(null) + + // Payment state + const paymentRequest = ref(null) + const paymentStatus = ref(null) + + // 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! + ) + } + + // In stock filter + if (filterData.value.inStock !== null) { + filtered = filtered.filter(product => + filterData.value.inStock ? product.quantity > 0 : product.quantity === 0 + ) + } + + // Payment methods filter + if (filterData.value.paymentMethods.length > 0) { + // For now, assume all products support Lightning payments + // This can be enhanced later with product-specific payment method support + filtered = filtered.filter(_product => true) + } + + // 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) + })) + }) + + // Enhanced cart computed properties + const allStallCarts = computed(() => Object.values(stallCarts.value)) + + const totalCartItems = computed(() => { + return allStallCarts.value.reduce((total, cart) => { + return total + cart.products.reduce((cartTotal, item) => cartTotal + item.quantity, 0) + }, 0) + }) + + const totalCartValue = computed(() => { + return allStallCarts.value.reduce((total, cart) => { + return total + cart.subtotal + }, 0) + }) + + const activeStallCart = computed(() => { + if (!checkoutStall.value) return null + return stallCarts.value[checkoutStall.value.id] || null + }) + + // Legacy cart computed properties (to be deprecated) + 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 setError = (errorMessage: string | null) => { + error.value = errorMessage + } + + 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 = {} + } + + // Enhanced cart management methods + const addToStallCart = (product: Product, quantity: number = 1) => { + const stallId = product.stall_id + const stall = stalls.value.find(s => s.id === stallId) + + if (!stall) { + console.error('Stall not found for product:', product.id) + return + } + + // Initialize stall cart if it doesn't exist + if (!stallCarts.value[stallId]) { + stallCarts.value[stallId] = { + id: stallId, + merchant: stall.pubkey, + products: [], + subtotal: 0, + currency: stall.currency || 'sats' + } + } + + const cart = stallCarts.value[stallId] + const existingItem = cart.products.find(item => item.product.id === product.id) + + if (existingItem) { + existingItem.quantity = Math.min(existingItem.quantity + quantity, product.quantity) + } else { + cart.products.push({ + product, + quantity: Math.min(quantity, product.quantity), + stallId + }) + } + + // Update cart subtotal + updateStallCartSubtotal(stallId) + } + + const removeFromStallCart = (stallId: string, productId: string) => { + const cart = stallCarts.value[stallId] + if (cart) { + cart.products = cart.products.filter(item => item.product.id !== productId) + updateStallCartSubtotal(stallId) + + // Remove empty carts + if (cart.products.length === 0) { + delete stallCarts.value[stallId] + } + } + } + + const updateStallCartQuantity = (stallId: string, productId: string, quantity: number) => { + const cart = stallCarts.value[stallId] + if (cart) { + if (quantity <= 0) { + removeFromStallCart(stallId, productId) + } else { + const item = cart.products.find(item => item.product.id === productId) + if (item) { + item.quantity = Math.min(quantity, item.product.quantity) + updateStallCartSubtotal(stallId) + } + } + } + } + + const updateStallCartSubtotal = (stallId: string) => { + const cart = stallCarts.value[stallId] + if (cart) { + cart.subtotal = cart.products.reduce((total, item) => { + return total + (item.product.price * item.quantity) + }, 0) + } + } + + const clearStallCart = (stallId: string) => { + delete stallCarts.value[stallId] + } + + const clearAllStallCarts = () => { + stallCarts.value = {} + } + + const setCheckoutCart = (stallId: string) => { + const cart = stallCarts.value[stallId] + const stall = stalls.value.find(s => s.id === stallId) + + if (cart && stall) { + checkoutCart.value = cart + checkoutStall.value = stall + } + } + + const clearCheckout = () => { + checkoutCart.value = null + checkoutStall.value = null + activeOrder.value = null + paymentRequest.value = null + paymentStatus.value = null + } + + const setShippingZone = (stallId: string, shippingZone: ShippingZone) => { + const cart = stallCarts.value[stallId] + if (cart) { + cart.shippingZone = shippingZone + } + } + + // Order management methods + const createOrder = (orderData: Omit & { id?: string }) => { + const order: Order = { + ...orderData, + id: orderData.id || generateOrderId(), + createdAt: Math.floor(Date.now() / 1000), + updatedAt: Math.floor(Date.now() / 1000) + } + + orders.value[order.id] = order + activeOrder.value = order + + // Save to localStorage for persistence + saveOrdersToStorage() + + return order + } + + const createAndPlaceOrder = async (orderData: Omit) => { + try { + // Create the order + const order = createOrder(orderData) + + // Attempt to publish order via nostrmarket protocol + let nostrmarketSuccess = false + let nostrmarketError: string | undefined + + try { + // Publish the order event to nostrmarket using proper protocol + const eventId = await nostrmarketService.publishOrder(order, order.sellerPubkey) + nostrmarketSuccess = true + order.sentViaNostr = true + order.nostrEventId = eventId + + console.log('Order published via nostrmarket successfully:', eventId) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown nostrmarket error' + order.nostrError = errorMessage + order.sentViaNostr = false + console.error('Failed to publish order via nostrmarket:', errorMessage) + } + + // Update order status to 'pending' + updateOrderStatus(order.id, 'pending') + + // Clear the checkout cart + if (checkoutCart.value) { + clearStallCart(checkoutCart.value.id) + } + + // Clear checkout state + clearCheckout() + + // Show appropriate success/error message + if (nostrmarketSuccess) { + console.log('Order created and published via nostrmarket successfully') + } else { + console.warn('Order created but nostrmarket publishing failed:', nostrmarketError) + } + + return order + } catch (error) { + console.error('Failed to create and place order:', error) + throw new Error('Failed to place order. Please try again.') + } + } + + // nostrmarket integration methods + const publishToNostrmarket = async () => { + try { + console.log('Publishing merchant catalog to nostrmarket...') + + // Get all stalls and products + const allStalls = Object.values(stalls.value) + const allProducts = Object.values(products.value) + + if (allStalls.length === 0) { + console.warn('No stalls to publish to nostrmarket') + return null + } + + if (allProducts.length === 0) { + console.warn('No products to publish to nostrmarket') + return null + } + + // Publish to nostrmarket + const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts) + + console.log('Successfully published to nostrmarket:', result) + + // Update stalls and products with event IDs + for (const [stallId, eventId] of Object.entries(result.stalls)) { + const stall = stalls.value.find(s => s.id === stallId) + if (stall) { + stall.nostrEventId = eventId + } + } + + for (const [productId, eventId] of Object.entries(result.products)) { + const product = products.value.find(p => p.id === productId) + if (product) { + product.nostrEventId = eventId + } + } + + return result + } catch (error) { + console.error('Failed to publish to nostrmarket:', error) + throw error + } + } + + // Invoice management methods + const createLightningInvoice = async (orderId: string, adminKey: string): Promise => { + try { + const order = orders.value[orderId] + if (!order) { + throw new Error('Order not found') + } + + // Create Lightning invoice with admin key and nostrmarket tag + // For nostrmarket compatibility, we need to use the original order ID if it exists + // If no originalOrderId exists, this order was created in the web-app, so use the current orderId + const orderIdForInvoice = order.originalOrderId || orderId + console.log('Creating invoice with order ID:', { + webAppOrderId: orderId, + originalOrderId: order.originalOrderId, + orderIdForInvoice: orderIdForInvoice, + hasOriginalOrderId: !!order.originalOrderId + }) + + const invoice = await invoiceService.createInvoice(order, adminKey, { + tag: "nostrmarket", + order_id: orderIdForInvoice, // Use original Nostr order ID for nostrmarket compatibility + merchant_pubkey: order.sellerPubkey, + buyer_pubkey: order.buyerPubkey + }) + + // Update order with invoice details + order.lightningInvoice = invoice + order.paymentHash = invoice.payment_hash + order.paymentStatus = 'pending' + order.paymentRequest = invoice.bolt11 // Use bolt11 field from LNBits response + + // Save to localStorage after invoice creation + saveOrdersToStorage() + + // Start monitoring payment + await paymentMonitor.startMonitoring(order, invoice) + + // Set up payment update callback + paymentMonitor.onPaymentUpdate(orderId, (update) => { + handlePaymentUpdate(orderId, update) + }) + + console.log('Lightning invoice created for order:', { + orderId, + originalOrderId: order.originalOrderId, + nostrmarketOrderId: order.originalOrderId || orderId, + paymentHash: invoice.payment_hash, + amount: invoice.amount + }) + + return invoice + } catch (error) { + console.error('Failed to create Lightning invoice:', error) + throw new Error('Failed to create payment invoice') + } + } + + const handlePaymentUpdate = (orderId: string, update: any) => { + const order = orders.value[orderId] + if (!order) return + + // Update order payment status + order.paymentStatus = update.status + if (update.status === 'paid') { + order.paidAt = update.paidAt + updateOrderStatus(orderId, 'paid') + + // Send payment confirmation via Nostr + sendPaymentConfirmation(order) + } + + // Save to localStorage after payment update + saveOrdersToStorage() + + console.log('Payment status updated for order:', { + orderId, + status: update.status, + amount: update.amount + }) + } + + const sendPaymentConfirmation = async (order: Order) => { + try { + if (!nostrOrders.isReady.value) { + console.warn('Nostr not ready for payment confirmation') + return + } + + // Create payment confirmation message + // const confirmation = { + // type: 'payment_confirmation', + // orderId: order.id, + // paymentHash: order.paymentHash, + // amount: order.total, + // currency: order.currency, + // paidAt: order.paidAt, + // message: 'Payment received! Your order is being processed.' + // } + + // Send confirmation to customer + await nostrOrders.publishOrderEvent(order, order.buyerPubkey) + + console.log('Payment confirmation sent via Nostr') + } catch (error) { + console.error('Failed to send payment confirmation:', error) + } + } + + const getOrderInvoice = (orderId: string): LightningInvoice | null => { + const order = orders.value[orderId] + return order?.lightningInvoice || null + } + + const getOrderPaymentStatus = (orderId: string): 'pending' | 'paid' | 'expired' | null => { + const order = orders.value[orderId] + return order?.paymentStatus || null + } + + const updateOrderStatus = (orderId: string, status: OrderStatus) => { + const order = orders.value[orderId] + if (order) { + order.status = status + order.updatedAt = Date.now() / 1000 + saveOrdersToStorage() + } + } + + const updateOrder = (orderId: string, updatedOrder: Partial) => { + const order = orders.value[orderId] + if (order) { + Object.assign(order, updatedOrder) + order.updatedAt = Date.now() / 1000 + saveOrdersToStorage() + } + } + + const setPaymentRequest = (request: PaymentRequest) => { + paymentRequest.value = request + } + + const setPaymentStatus = (status: PaymentStatus) => { + paymentStatus.value = status + } + + // Utility methods + const generateOrderId = () => { + return `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + } + + // Persistence methods + const saveOrdersToStorage = () => { + try { + const storageKey = getUserStorageKey('market_orders') + localStorage.setItem(storageKey, JSON.stringify(orders.value)) + console.log('Saved orders to localStorage with key:', storageKey) + } catch (error) { + console.warn('Failed to save orders to localStorage:', error) + } + } + + const loadOrdersFromStorage = () => { + try { + const storageKey = getUserStorageKey('market_orders') + const stored = localStorage.getItem(storageKey) + if (stored) { + const parsedOrders = JSON.parse(stored) + orders.value = parsedOrders + console.log('Loaded orders from localStorage with key:', storageKey, 'Orders count:', Object.keys(parsedOrders).length) + } else { + console.log('No orders found in localStorage for key:', storageKey) + // Clear any existing orders when switching users + orders.value = {} + } + } catch (error) { + console.warn('Failed to load orders from localStorage:', error) + // Clear orders on error + orders.value = {} + } + } + + // Clear orders when user changes + const clearOrdersForUserChange = () => { + orders.value = {} + console.log('Cleared orders for user change') + } + + // Payment utility methods + const calculateOrderTotal = (cart: StallCart, shippingZone?: ShippingZone) => { + const subtotal = cart.subtotal + const shippingCost = shippingZone?.cost || 0 + return subtotal + shippingCost + } + + const validateCartForCheckout = (stallId: string): { valid: boolean; errors: string[] } => { + const cart = stallCarts.value[stallId] + const errors: string[] = [] + + if (!cart || cart.products.length === 0) { + errors.push('Cart is empty') + return { valid: false, errors } + } + + // Check if all products are still in stock + for (const item of cart.products) { + if (item.quantity > item.product.quantity) { + errors.push(`${item.product.name} is out of stock`) + } + } + + // Check if cart has shipping zone selected + if (!cart.shippingZone) { + errors.push('Please select a shipping zone') + } + + return { valid: errors.length === 0, errors } + } + + const getCartSummary = (stallId: string) => { + const cart = stallCarts.value[stallId] + if (!cart) return null + + const itemCount = cart.products.reduce((total, item) => total + item.quantity, 0) + const subtotal = cart.subtotal + const shippingCost = cart.shippingZone?.cost || 0 + const total = subtotal + shippingCost + + return { + itemCount, + subtotal, + shippingCost, + total, + currency: cart.currency + } + } + + const updateFilterData = (newFilterData: Partial) => { + filterData.value = { ...filterData.value, ...newFilterData } + } + + const clearFilters = () => { + filterData.value = { + categories: [], + merchants: [], + stalls: [], + currency: null, + priceFrom: null, + priceTo: null, + inStock: null, + paymentMethods: [] + } + } + + 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) + } + + // Initialize orders from localStorage + loadOrdersFromStorage() + + // Watch for user changes and reload orders + watch(() => auth.currentUser?.value?.pubkey, (newPubkey, oldPubkey) => { + if (newPubkey !== oldPubkey) { + console.log('User changed, clearing and reloading orders. Old:', oldPubkey, 'New:', newPubkey) + clearOrdersForUserChange() + loadOrdersFromStorage() + } + }) + + 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), + error: readonly(error), + searchText: readonly(searchText), + showFilterDetails: readonly(showFilterDetails), + filterData: readonly(filterData), + sortOptions: readonly(sortOptions), + shoppingCart: readonly(shoppingCart), + stallCarts: readonly(stallCarts), + checkoutCart: readonly(checkoutCart), + checkoutStall: readonly(checkoutStall), + activeOrder: readonly(activeOrder), + paymentRequest: readonly(paymentRequest), + paymentStatus: readonly(paymentStatus), + + // Computed + filteredProducts, + allCategories, + allStallCarts, + totalCartItems, + totalCartValue, + activeStallCart, + cartTotal, + cartItemCount, + + // Actions + setLoading, + setError, + setSearchText, + setActiveMarket, + setActiveStall, + setActiveProduct, + addProduct, + addStall, + addMarket, + addToCart, + removeFromCart, + updateCartQuantity, + clearCart, + updateFilterData, + clearFilters, + toggleCategoryFilter, + updateSortOptions, + formatPrice, + addToStallCart, + removeFromStallCart, + updateStallCartQuantity, + updateStallCartSubtotal, + clearStallCart, + clearAllStallCarts, + setCheckoutCart, + clearCheckout, + setShippingZone, + createOrder, + updateOrderStatus, + setPaymentRequest, + setPaymentStatus, + generateOrderId, + calculateOrderTotal, + validateCartForCheckout, + getCartSummary, + createAndPlaceOrder, + createLightningInvoice, + handlePaymentUpdate, + sendPaymentConfirmation, + getOrderInvoice, + getOrderPaymentStatus, + updateOrder, + saveOrdersToStorage, + loadOrdersFromStorage, + clearOrdersForUserChange, + publishToNostrmarket + } +}) \ No newline at end of file diff --git a/src/modules/market/types/market.ts b/src/modules/market/types/market.ts new file mode 100644 index 0000000..d8dfd79 --- /dev/null +++ b/src/modules/market/types/market.ts @@ -0,0 +1,150 @@ +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?: ShippingZone[] + currency: string + nostrEventId?: string +} + +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 + nostrEventId?: string +} + +export interface Order { + id: string + cartId: string + stallId: string + buyerPubkey: string + sellerPubkey: string + status: OrderStatus + items: OrderItem[] + contactInfo: ContactInfo + shippingZone: ShippingZone + paymentRequest?: string + paymentMethod: PaymentMethod + subtotal: number + shippingCost: number + total: number + currency: string + createdAt: number + updatedAt: number + nostrEventId?: string + nostrEventSig?: string + sentViaNostr?: boolean + nostrError?: string + originalOrderId?: string + lightningInvoice?: any + paymentHash?: string + paidAt?: number + paymentStatus?: 'pending' | 'paid' | 'expired' + qrCodeDataUrl?: string + qrCodeLoading?: boolean + qrCodeError?: string | null + showQRCode?: boolean +} + +export interface OrderItem { + productId: string + productName: string + quantity: number + price: number + currency: string +} + +export interface ContactInfo { + address?: string + email?: string + message?: string + npub?: string +} + +export interface ShippingZone { + id: string + name: string + cost: number + currency: string + description?: string + estimatedDays?: string + requiresPhysicalShipping?: boolean +} + +export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing' + +export type PaymentMethod = 'lightning' | 'btc_onchain' + +export interface CartItem { + product: Product + quantity: number + stallId: string +} + +export interface StallCart { + id: string + merchant: string + products: CartItem[] + subtotal: number + shippingZone?: ShippingZone + currency: string +} + +export interface FilterData { + categories: string[] + merchants: string[] + stalls: string[] + currency: string | null + priceFrom: number | null + priceTo: number | null + inStock: boolean | null + paymentMethods: PaymentMethod[] +} + +export interface SortOptions { + field: string + order: 'asc' | 'desc' +} + +export interface PaymentRequest { + paymentRequest: string + amount: number + currency: string + expiresAt: number + description: string +} + +export interface PaymentStatus { + paid: boolean + amountPaid: number + paidAt?: number + transactionId?: string +} \ No newline at end of file diff --git a/src/modules/market/views/MarketDashboard.vue b/src/modules/market/views/MarketDashboard.vue new file mode 100644 index 0000000..029b358 --- /dev/null +++ b/src/modules/market/views/MarketDashboard.vue @@ -0,0 +1,125 @@ + + + + diff --git a/src/modules/market/views/MarketPage.vue b/src/modules/market/views/MarketPage.vue new file mode 100644 index 0000000..f61c9ad --- /dev/null +++ b/src/modules/market/views/MarketPage.vue @@ -0,0 +1,187 @@ + + + \ No newline at end of file diff --git a/src/pages/Market.vue b/src/pages/Market.vue index 698c0ba..2dc5af9 100644 --- a/src/pages/Market.vue +++ b/src/pages/Market.vue @@ -1,187 +1,4 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/pages/MarketDashboard.vue b/src/pages/MarketDashboard.vue index f1c9ad2..e56d270 100644 --- a/src/pages/MarketDashboard.vue +++ b/src/pages/MarketDashboard.vue @@ -1,125 +1,4 @@ - - - - + \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index a55d557..ea68793 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -22,33 +22,6 @@ const router = createRouter({ requiresAuth: false } }, - { - path: '/events', - name: 'events', - component: () => import('@/pages/events.vue'), - meta: { - title: 'Events', - requiresAuth: true - } - }, - { - path: '/my-tickets', - name: 'my-tickets', - component: () => import('@/pages/MyTickets.vue'), - meta: { - title: 'My Tickets', - requiresAuth: true - } - }, - { - path: '/market', - name: 'market', - component: () => import('@/pages/Market.vue'), - meta: { - title: 'Market', - requiresAuth: true - } - }, { path: '/cart', name: 'cart', @@ -73,21 +46,6 @@ const router = createRouter({ component: () => import('@/pages/OrderHistory.vue'), meta: { requiresAuth: true } }, - { - path: '/market-dashboard', - name: 'MarketDashboard', - component: () => import('@/pages/MarketDashboard.vue'), - meta: { requiresAuth: true } - }, - { - path: '/chat', - name: 'chat', - component: () => import('@/pages/ChatPage.vue'), - meta: { - title: 'Nostr Chat', - requiresAuth: true - } - }, { path: '/relay-hub-status', name: 'relay-hub-status', diff --git a/src/stores/market.ts b/src/stores/market.ts index 798a8cd..7f2a19e 100644 --- a/src/stores/market.ts +++ b/src/stores/market.ts @@ -1,1036 +1,4 @@ -import { defineStore } from 'pinia' -import { ref, computed, readonly, watch } from 'vue' -import { nostrOrders } from '@/composables/useNostrOrders' -import { invoiceService } from '@/lib/services/invoiceService' -import { paymentMonitor } from '@/lib/services/paymentMonitor' -import { nostrmarketService } from '@/lib/services/nostrmarketService' -import { useAuth } from '@/composables/useAuth' -import type { LightningInvoice } from '@/lib/services/invoiceService' - - -// 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?: ShippingZone[] - currency: string - nostrEventId?: string // Nostr event ID for nostrmarket integration -} - -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 - nostrEventId?: string // Nostr event ID for nostrmarket integration -} - -// Enhanced Order interface for the new system -export interface Order { - id: string - cartId: string - stallId: string - buyerPubkey: string - sellerPubkey: string - status: OrderStatus - items: OrderItem[] - contactInfo: ContactInfo - shippingZone: ShippingZone - paymentRequest?: string - paymentMethod: PaymentMethod - subtotal: number - shippingCost: number - total: number - currency: string - createdAt: number - updatedAt: number - // Nostr integration fields - nostrEventId?: string - nostrEventSig?: string - sentViaNostr?: boolean - nostrError?: string - originalOrderId?: string // Original order ID from Nostr event (for nostrmarket compatibility) - // Lightning invoice fields - lightningInvoice?: LightningInvoice - paymentHash?: string - paidAt?: number - paymentStatus?: 'pending' | 'paid' | 'expired' - // QR code fields - qrCodeDataUrl?: string - qrCodeLoading?: boolean - qrCodeError?: string | null - showQRCode?: boolean // Toggle QR code visibility -} - -export interface OrderItem { - productId: string - productName: string - quantity: number - price: number - currency: string -} - -export interface ContactInfo { - address?: string - email?: string - message?: string - npub?: string -} - -export interface ShippingZone { - id: string - name: string - cost: number - currency: string - description?: string - estimatedDays?: string - requiresPhysicalShipping?: boolean -} - -export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'cancelled' | 'processing' - -export type PaymentMethod = 'lightning' | 'btc_onchain' - -// Cart management interfaces -export interface CartItem { - product: Product - quantity: number - stallId: string -} - -export interface StallCart { - id: string - merchant: string - products: CartItem[] - subtotal: number - shippingZone?: ShippingZone - currency: string -} - -// Enhanced FilterData with more options -export interface FilterData { - categories: string[] - merchants: string[] - stalls: string[] - currency: string | null - priceFrom: number | null - priceTo: number | null - inStock: boolean | null - paymentMethods: PaymentMethod[] -} - -export interface SortOptions { - field: string - order: 'asc' | 'desc' -} - -// Payment-related interfaces -export interface PaymentRequest { - paymentRequest: string - amount: number - currency: string - expiresAt: number - description: string -} - -export interface PaymentStatus { - paid: boolean - amountPaid: number - paidAt?: number - transactionId?: string -} - -export const useMarketStore = defineStore('market', () => { - const auth = useAuth() - - // Helper function to get user-specific storage key - const getUserStorageKey = (baseKey: string) => { - const userPubkey = auth.currentUser?.value?.pubkey - return userPubkey ? `${baseKey}_${userPubkey}` : baseKey - } - // 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 error = ref(null) - const searchText = ref('') - const showFilterDetails = ref(false) - - // Filtering and sorting - const filterData = ref({ - categories: [], - merchants: [], - stalls: [], - currency: null, - priceFrom: null, - priceTo: null, - inStock: null, - paymentMethods: [] - }) - - const sortOptions = ref({ - field: 'name', - order: 'asc' - }) - - // Enhanced shopping cart with stall-specific carts - const stallCarts = ref>({}) - - // Legacy shopping cart (to be deprecated) - const shoppingCart = ref>({}) - - // Checkout state - const checkoutCart = ref(null) - const checkoutStall = ref(null) - const activeOrder = ref(null) - - // Payment state - const paymentRequest = ref(null) - const paymentStatus = ref(null) - - // 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! - ) - } - - // In stock filter - if (filterData.value.inStock !== null) { - filtered = filtered.filter(product => - filterData.value.inStock ? product.quantity > 0 : product.quantity === 0 - ) - } - - // Payment methods filter - if (filterData.value.paymentMethods.length > 0) { - // For now, assume all products support Lightning payments - // This can be enhanced later with product-specific payment method support - filtered = filtered.filter(_product => true) - } - - // 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) - })) - }) - - // Enhanced cart computed properties - const allStallCarts = computed(() => Object.values(stallCarts.value)) - - const totalCartItems = computed(() => { - return allStallCarts.value.reduce((total, cart) => { - return total + cart.products.reduce((cartTotal, item) => cartTotal + item.quantity, 0) - }, 0) - }) - - const totalCartValue = computed(() => { - return allStallCarts.value.reduce((total, cart) => { - return total + cart.subtotal - }, 0) - }) - - const activeStallCart = computed(() => { - if (!checkoutStall.value) return null - return stallCarts.value[checkoutStall.value.id] || null - }) - - // Legacy cart computed properties (to be deprecated) - 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 setError = (errorMessage: string | null) => { - error.value = errorMessage - } - - 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 = {} - } - - // Enhanced cart management methods - const addToStallCart = (product: Product, quantity: number = 1) => { - const stallId = product.stall_id - const stall = stalls.value.find(s => s.id === stallId) - - if (!stall) { - console.error('Stall not found for product:', product.id) - return - } - - // Initialize stall cart if it doesn't exist - if (!stallCarts.value[stallId]) { - stallCarts.value[stallId] = { - id: stallId, - merchant: stall.pubkey, - products: [], - subtotal: 0, - currency: stall.currency || 'sats' - } - } - - const cart = stallCarts.value[stallId] - const existingItem = cart.products.find(item => item.product.id === product.id) - - if (existingItem) { - existingItem.quantity = Math.min(existingItem.quantity + quantity, product.quantity) - } else { - cart.products.push({ - product, - quantity: Math.min(quantity, product.quantity), - stallId - }) - } - - // Update cart subtotal - updateStallCartSubtotal(stallId) - } - - const removeFromStallCart = (stallId: string, productId: string) => { - const cart = stallCarts.value[stallId] - if (cart) { - cart.products = cart.products.filter(item => item.product.id !== productId) - updateStallCartSubtotal(stallId) - - // Remove empty carts - if (cart.products.length === 0) { - delete stallCarts.value[stallId] - } - } - } - - const updateStallCartQuantity = (stallId: string, productId: string, quantity: number) => { - const cart = stallCarts.value[stallId] - if (cart) { - if (quantity <= 0) { - removeFromStallCart(stallId, productId) - } else { - const item = cart.products.find(item => item.product.id === productId) - if (item) { - item.quantity = Math.min(quantity, item.product.quantity) - updateStallCartSubtotal(stallId) - } - } - } - } - - const updateStallCartSubtotal = (stallId: string) => { - const cart = stallCarts.value[stallId] - if (cart) { - cart.subtotal = cart.products.reduce((total, item) => { - return total + (item.product.price * item.quantity) - }, 0) - } - } - - const clearStallCart = (stallId: string) => { - delete stallCarts.value[stallId] - } - - const clearAllStallCarts = () => { - stallCarts.value = {} - } - - const setCheckoutCart = (stallId: string) => { - const cart = stallCarts.value[stallId] - const stall = stalls.value.find(s => s.id === stallId) - - if (cart && stall) { - checkoutCart.value = cart - checkoutStall.value = stall - } - } - - const clearCheckout = () => { - checkoutCart.value = null - checkoutStall.value = null - activeOrder.value = null - paymentRequest.value = null - paymentStatus.value = null - } - - const setShippingZone = (stallId: string, shippingZone: ShippingZone) => { - const cart = stallCarts.value[stallId] - if (cart) { - cart.shippingZone = shippingZone - } - } - - // Order management methods - const createOrder = (orderData: Omit & { id?: string }) => { - const order: Order = { - ...orderData, - id: orderData.id || generateOrderId(), - createdAt: Math.floor(Date.now() / 1000), - updatedAt: Math.floor(Date.now() / 1000) - } - - orders.value[order.id] = order - activeOrder.value = order - - // Save to localStorage for persistence - saveOrdersToStorage() - - return order - } - - const createAndPlaceOrder = async (orderData: Omit) => { - try { - // Create the order - const order = createOrder(orderData) - - // Attempt to publish order via nostrmarket protocol - let nostrmarketSuccess = false - let nostrmarketError: string | undefined - - try { - // Publish the order event to nostrmarket using proper protocol - const eventId = await nostrmarketService.publishOrder(order, order.sellerPubkey) - nostrmarketSuccess = true - order.sentViaNostr = true - order.nostrEventId = eventId - - console.log('Order published via nostrmarket successfully:', eventId) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown nostrmarket error' - order.nostrError = errorMessage - order.sentViaNostr = false - console.error('Failed to publish order via nostrmarket:', errorMessage) - } - - // Update order status to 'pending' - updateOrderStatus(order.id, 'pending') - - // Clear the checkout cart - if (checkoutCart.value) { - clearStallCart(checkoutCart.value.id) - } - - // Clear checkout state - clearCheckout() - - // Show appropriate success/error message - if (nostrmarketSuccess) { - console.log('Order created and published via nostrmarket successfully') - } else { - console.warn('Order created but nostrmarket publishing failed:', nostrmarketError) - } - - return order - } catch (error) { - console.error('Failed to create and place order:', error) - throw new Error('Failed to place order. Please try again.') - } - } - - // nostrmarket integration methods - const publishToNostrmarket = async () => { - try { - console.log('Publishing merchant catalog to nostrmarket...') - - // Get all stalls and products - const allStalls = Object.values(stalls.value) - const allProducts = Object.values(products.value) - - if (allStalls.length === 0) { - console.warn('No stalls to publish to nostrmarket') - return null - } - - if (allProducts.length === 0) { - console.warn('No products to publish to nostrmarket') - return null - } - - // Publish to nostrmarket - const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts) - - console.log('Successfully published to nostrmarket:', result) - - // Update stalls and products with event IDs - for (const [stallId, eventId] of Object.entries(result.stalls)) { - const stall = stalls.value.find(s => s.id === stallId) - if (stall) { - stall.nostrEventId = eventId - } - } - - for (const [productId, eventId] of Object.entries(result.products)) { - const product = products.value.find(p => p.id === productId) - if (product) { - product.nostrEventId = eventId - } - } - - return result - } catch (error) { - console.error('Failed to publish to nostrmarket:', error) - throw error - } - } - - // Invoice management methods - const createLightningInvoice = async (orderId: string, adminKey: string): Promise => { - try { - const order = orders.value[orderId] - if (!order) { - throw new Error('Order not found') - } - - // Create Lightning invoice with admin key and nostrmarket tag - // For nostrmarket compatibility, we need to use the original order ID if it exists - // If no originalOrderId exists, this order was created in the web-app, so use the current orderId - const orderIdForInvoice = order.originalOrderId || orderId - console.log('Creating invoice with order ID:', { - webAppOrderId: orderId, - originalOrderId: order.originalOrderId, - orderIdForInvoice: orderIdForInvoice, - hasOriginalOrderId: !!order.originalOrderId - }) - - const invoice = await invoiceService.createInvoice(order, adminKey, { - tag: "nostrmarket", - order_id: orderIdForInvoice, // Use original Nostr order ID for nostrmarket compatibility - merchant_pubkey: order.sellerPubkey, - buyer_pubkey: order.buyerPubkey - }) - - // Update order with invoice details - order.lightningInvoice = invoice - order.paymentHash = invoice.payment_hash - order.paymentStatus = 'pending' - order.paymentRequest = invoice.bolt11 // Use bolt11 field from LNBits response - - // Save to localStorage after invoice creation - saveOrdersToStorage() - - // Start monitoring payment - await paymentMonitor.startMonitoring(order, invoice) - - // Set up payment update callback - paymentMonitor.onPaymentUpdate(orderId, (update) => { - handlePaymentUpdate(orderId, update) - }) - - console.log('Lightning invoice created for order:', { - orderId, - originalOrderId: order.originalOrderId, - nostrmarketOrderId: order.originalOrderId || orderId, - paymentHash: invoice.payment_hash, - amount: invoice.amount - }) - - return invoice - } catch (error) { - console.error('Failed to create Lightning invoice:', error) - throw new Error('Failed to create payment invoice') - } - } - - const handlePaymentUpdate = (orderId: string, update: any) => { - const order = orders.value[orderId] - if (!order) return - - // Update order payment status - order.paymentStatus = update.status - if (update.status === 'paid') { - order.paidAt = update.paidAt - updateOrderStatus(orderId, 'paid') - - // Send payment confirmation via Nostr - sendPaymentConfirmation(order) - } - - // Save to localStorage after payment update - saveOrdersToStorage() - - console.log('Payment status updated for order:', { - orderId, - status: update.status, - amount: update.amount - }) - } - - const sendPaymentConfirmation = async (order: Order) => { - try { - if (!nostrOrders.isReady.value) { - console.warn('Nostr not ready for payment confirmation') - return - } - - // Create payment confirmation message - // const confirmation = { - // type: 'payment_confirmation', - // orderId: order.id, - // paymentHash: order.paymentHash, - // amount: order.total, - // currency: order.currency, - // paidAt: order.paidAt, - // message: 'Payment received! Your order is being processed.' - // } - - // Send confirmation to customer - await nostrOrders.publishOrderEvent(order, order.buyerPubkey) - - console.log('Payment confirmation sent via Nostr') - } catch (error) { - console.error('Failed to send payment confirmation:', error) - } - } - - const getOrderInvoice = (orderId: string): LightningInvoice | null => { - const order = orders.value[orderId] - return order?.lightningInvoice || null - } - - const getOrderPaymentStatus = (orderId: string): 'pending' | 'paid' | 'expired' | null => { - const order = orders.value[orderId] - return order?.paymentStatus || null - } - - const updateOrderStatus = (orderId: string, status: OrderStatus) => { - const order = orders.value[orderId] - if (order) { - order.status = status - order.updatedAt = Date.now() / 1000 - saveOrdersToStorage() - } - } - - const updateOrder = (orderId: string, updatedOrder: Partial) => { - const order = orders.value[orderId] - if (order) { - Object.assign(order, updatedOrder) - order.updatedAt = Date.now() / 1000 - saveOrdersToStorage() - } - } - - const setPaymentRequest = (request: PaymentRequest) => { - paymentRequest.value = request - } - - const setPaymentStatus = (status: PaymentStatus) => { - paymentStatus.value = status - } - - // Utility methods - const generateOrderId = () => { - return `order_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - } - - // Persistence methods - const saveOrdersToStorage = () => { - try { - const storageKey = getUserStorageKey('market_orders') - localStorage.setItem(storageKey, JSON.stringify(orders.value)) - console.log('Saved orders to localStorage with key:', storageKey) - } catch (error) { - console.warn('Failed to save orders to localStorage:', error) - } - } - - const loadOrdersFromStorage = () => { - try { - const storageKey = getUserStorageKey('market_orders') - const stored = localStorage.getItem(storageKey) - if (stored) { - const parsedOrders = JSON.parse(stored) - orders.value = parsedOrders - console.log('Loaded orders from localStorage with key:', storageKey, 'Orders count:', Object.keys(parsedOrders).length) - } else { - console.log('No orders found in localStorage for key:', storageKey) - // Clear any existing orders when switching users - orders.value = {} - } - } catch (error) { - console.warn('Failed to load orders from localStorage:', error) - // Clear orders on error - orders.value = {} - } - } - - // Clear orders when user changes - const clearOrdersForUserChange = () => { - orders.value = {} - console.log('Cleared orders for user change') - } - - // Payment utility methods - const calculateOrderTotal = (cart: StallCart, shippingZone?: ShippingZone) => { - const subtotal = cart.subtotal - const shippingCost = shippingZone?.cost || 0 - return subtotal + shippingCost - } - - const validateCartForCheckout = (stallId: string): { valid: boolean; errors: string[] } => { - const cart = stallCarts.value[stallId] - const errors: string[] = [] - - if (!cart || cart.products.length === 0) { - errors.push('Cart is empty') - return { valid: false, errors } - } - - // Check if all products are still in stock - for (const item of cart.products) { - if (item.quantity > item.product.quantity) { - errors.push(`${item.product.name} is out of stock`) - } - } - - // Check if cart has shipping zone selected - if (!cart.shippingZone) { - errors.push('Please select a shipping zone') - } - - return { valid: errors.length === 0, errors } - } - - const getCartSummary = (stallId: string) => { - const cart = stallCarts.value[stallId] - if (!cart) return null - - const itemCount = cart.products.reduce((total, item) => total + item.quantity, 0) - const subtotal = cart.subtotal - const shippingCost = cart.shippingZone?.cost || 0 - const total = subtotal + shippingCost - - return { - itemCount, - subtotal, - shippingCost, - total, - currency: cart.currency - } - } - - const updateFilterData = (newFilterData: Partial) => { - filterData.value = { ...filterData.value, ...newFilterData } - } - - const clearFilters = () => { - filterData.value = { - categories: [], - merchants: [], - stalls: [], - currency: null, - priceFrom: null, - priceTo: null, - inStock: null, - paymentMethods: [] - } - } - - 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) - } - - // Initialize orders from localStorage - loadOrdersFromStorage() - - // Watch for user changes and reload orders - watch(() => auth.currentUser?.value?.pubkey, (newPubkey, oldPubkey) => { - if (newPubkey !== oldPubkey) { - console.log('User changed, clearing and reloading orders. Old:', oldPubkey, 'New:', newPubkey) - clearOrdersForUserChange() - loadOrdersFromStorage() - } - }) - - 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), - error: readonly(error), - searchText: readonly(searchText), - showFilterDetails: readonly(showFilterDetails), - filterData: readonly(filterData), - sortOptions: readonly(sortOptions), - shoppingCart: readonly(shoppingCart), - stallCarts: readonly(stallCarts), - checkoutCart: readonly(checkoutCart), - checkoutStall: readonly(checkoutStall), - activeOrder: readonly(activeOrder), - paymentRequest: readonly(paymentRequest), - paymentStatus: readonly(paymentStatus), - - // Computed - filteredProducts, - allCategories, - allStallCarts, - totalCartItems, - totalCartValue, - activeStallCart, - cartTotal, - cartItemCount, - - // Actions - setLoading, - setError, - setSearchText, - setActiveMarket, - setActiveStall, - setActiveProduct, - addProduct, - addStall, - addMarket, - addToCart, - removeFromCart, - updateCartQuantity, - clearCart, - updateFilterData, - clearFilters, - toggleCategoryFilter, - updateSortOptions, - formatPrice, - addToStallCart, - removeFromStallCart, - updateStallCartQuantity, - updateStallCartSubtotal, - clearStallCart, - clearAllStallCarts, - setCheckoutCart, - clearCheckout, - setShippingZone, - createOrder, - updateOrderStatus, - setPaymentRequest, - setPaymentStatus, - generateOrderId, - calculateOrderTotal, - validateCartForCheckout, - getCartSummary, - createAndPlaceOrder, - createLightningInvoice, - handlePaymentUpdate, - sendPaymentConfirmation, - getOrderInvoice, - getOrderPaymentStatus, - updateOrder, - saveOrdersToStorage, - loadOrdersFromStorage, - clearOrdersForUserChange, - publishToNostrmarket - } -}) \ No newline at end of file +// Compatibility re-export for the moved market store +export * from '@/modules/market/stores/market' +export { useMarketStore } from '@/modules/market/stores/market' +export type * from '@/modules/market/types/market' \ No newline at end of file