diff --git a/CLAUDE.md b/CLAUDE.md index 9e3327e..6fd5d02 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,6 +33,12 @@ The application uses a plugin-based modular architecture with dependency injecti - **Events Module** (`src/modules/events/`) - Event ticketing with Lightning payments - **Market Module** (`src/modules/market/`) - Nostr marketplace functionality +**IMPORTANT - Market Event Publishing Strategy:** +- **LNbits "nostrmarket" extension handles ALL market event publishing** (merchants, stalls, products) to Nostr relays +- **Web-app does NOT publish** merchant/stall/product events - only processes incoming events from relays +- **Exception: Checkout/Order events** - Web-app publishes order events directly to Nostr during checkout process +- This division ensures consistency and prevents duplicate publishing while allowing real-time order placement + **Module Configuration:** - Modules are configured in `src/app.config.ts` - Each module can be enabled/disabled and configured independently @@ -730,6 +736,33 @@ export function useMyModule() { - **ALWAYS extend BaseService for module services** - **NEVER create direct dependencies between modules** +### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention** + +**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:** + +```typescript +// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0 +quantity: productData.quantity || 1 + +// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined +quantity: productData.quantity ?? 1 +``` + +**Why this matters:** +- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN` +- Using `||` for defaults will incorrectly override valid `0` values +- This caused a critical bug where products with quantity `0` displayed as quantity `1` +- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values + +**Common scenarios where this bug occurs:** +- Product quantities, prices, counters (any numeric value where 0 is valid) +- Boolean flags where `false` is a valid state +- Empty strings that should be preserved vs. undefined strings + +**Rule of thumb:** +- Use `||` only when `0`, `false`, or `""` should trigger the default +- Use `??` when only `null`/`undefined` should trigger the default (most cases) + **Build Configuration:** - Vite config includes PWA, image optimization, and bundle analysis - Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn) diff --git a/package-lock.json b/package-lock.json index 2250669..aa24fb0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "aio-shadcn-vite", "version": "0.0.0", "dependencies": { - "@tanstack/vue-table": "^8.21.2", + "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", "@vueuse/components": "^12.5.0", "@vueuse/core": "^12.8.2", @@ -4950,9 +4950,9 @@ } }, "node_modules/@tanstack/table-core": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz", - "integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", "license": "MIT", "engines": { "node": ">=12" @@ -4973,12 +4973,12 @@ } }, "node_modules/@tanstack/vue-table": { - "version": "8.21.2", - "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.2.tgz", - "integrity": "sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz", + "integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==", "license": "MIT", "dependencies": { - "@tanstack/table-core": "8.21.2" + "@tanstack/table-core": "8.21.3" }, "engines": { "node": ">=12" diff --git a/package.json b/package.json index 34de2b4..304528e 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "make": "electron-forge make" }, "dependencies": { - "@tanstack/vue-table": "^8.21.2", + "@tanstack/vue-table": "^8.21.3", "@vee-validate/zod": "^4.15.1", "@vueuse/components": "^12.5.0", "@vueuse/core": "^12.8.2", diff --git a/src/app.config.ts b/src/app.config.ts index a60693e..8060762 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -54,7 +54,12 @@ export const appConfig: AppConfig = { config: { maxMessages: 500, autoScroll: true, - showTimestamps: true + showTimestamps: true, + notifications: { + enabled: true, + soundEnabled: false, + wildcardSupport: true + } } }, events: { diff --git a/src/components/ui/image/ImageLightbox.vue b/src/components/ui/image/ImageLightbox.vue new file mode 100644 index 0000000..e3049fe --- /dev/null +++ b/src/components/ui/image/ImageLightbox.vue @@ -0,0 +1,241 @@ + + + + + \ No newline at end of file diff --git a/src/components/ui/image/ImageViewer.vue b/src/components/ui/image/ImageViewer.vue new file mode 100644 index 0000000..c0e0220 --- /dev/null +++ b/src/components/ui/image/ImageViewer.vue @@ -0,0 +1,334 @@ + + + + + \ No newline at end of file diff --git a/src/components/ui/ProgressiveImage.vue b/src/components/ui/image/ProgressiveImage.vue similarity index 95% rename from src/components/ui/ProgressiveImage.vue rename to src/components/ui/image/ProgressiveImage.vue index 042546c..cb82a3a 100644 --- a/src/components/ui/ProgressiveImage.vue +++ b/src/components/ui/image/ProgressiveImage.vue @@ -17,7 +17,7 @@ 'progressive-image-loaded': isLoaded, 'progressive-image-error': hasError } - ]" :loading="loading" @load="handleLoad" @error="handleError" /> --> + ]" :loading="loading" @load="handleLoad" @error="handleError" />
@@ -37,7 +37,7 @@ + + diff --git a/src/components/ui/table/TableBody.vue b/src/components/ui/table/TableBody.vue new file mode 100644 index 0000000..58d1ba6 --- /dev/null +++ b/src/components/ui/table/TableBody.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableCaption.vue b/src/components/ui/table/TableCaption.vue new file mode 100644 index 0000000..baaa177 --- /dev/null +++ b/src/components/ui/table/TableCaption.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableCell.vue b/src/components/ui/table/TableCell.vue new file mode 100644 index 0000000..d42cf26 --- /dev/null +++ b/src/components/ui/table/TableCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/table/TableEmpty.vue b/src/components/ui/table/TableEmpty.vue new file mode 100644 index 0000000..c454dab --- /dev/null +++ b/src/components/ui/table/TableEmpty.vue @@ -0,0 +1,34 @@ + + + diff --git a/src/components/ui/table/TableFooter.vue b/src/components/ui/table/TableFooter.vue new file mode 100644 index 0000000..c3dc6b0 --- /dev/null +++ b/src/components/ui/table/TableFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHead.vue b/src/components/ui/table/TableHead.vue new file mode 100644 index 0000000..51ceec2 --- /dev/null +++ b/src/components/ui/table/TableHead.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHeader.vue b/src/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..4977b71 --- /dev/null +++ b/src/components/ui/table/TableHeader.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableRow.vue b/src/components/ui/table/TableRow.vue new file mode 100644 index 0000000..fd363c0 --- /dev/null +++ b/src/components/ui/table/TableRow.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/index.ts b/src/components/ui/table/index.ts new file mode 100644 index 0000000..3be308b --- /dev/null +++ b/src/components/ui/table/index.ts @@ -0,0 +1,9 @@ +export { default as Table } from "./Table.vue" +export { default as TableBody } from "./TableBody.vue" +export { default as TableCaption } from "./TableCaption.vue" +export { default as TableCell } from "./TableCell.vue" +export { default as TableEmpty } from "./TableEmpty.vue" +export { default as TableFooter } from "./TableFooter.vue" +export { default as TableHead } from "./TableHead.vue" +export { default as TableHeader } from "./TableHeader.vue" +export { default as TableRow } from "./TableRow.vue" diff --git a/src/components/ui/table/utils.ts b/src/components/ui/table/utils.ts new file mode 100644 index 0000000..3d4fd12 --- /dev/null +++ b/src/components/ui/table/utils.ts @@ -0,0 +1,10 @@ +import type { Updater } from "@tanstack/vue-table" + +import type { Ref } from "vue" +import { isFunction } from "@tanstack/vue-table" + +export function valueUpdater(updaterOrValue: Updater, ref: Ref) { + ref.value = isFunction(updaterOrValue) + ? updaterOrValue(ref.value) + : updaterOrValue +} diff --git a/src/modules/base/components/ImageDisplay.vue b/src/modules/base/components/ImageDisplay.vue deleted file mode 100644 index 0e18969..0000000 --- a/src/modules/base/components/ImageDisplay.vue +++ /dev/null @@ -1,238 +0,0 @@ - - - \ No newline at end of file diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index db6df6b..640482f 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -20,7 +20,6 @@ import { ImageUploadService } from './services/ImageUploadService' // Import components import ImageUpload from './components/ImageUpload.vue' -import ImageDisplay from './components/ImageDisplay.vue' // Create service instances const invoiceService = new InvoiceService() @@ -143,8 +142,7 @@ export const baseModule: ModulePlugin = { // Export components for use by other modules components: { - ImageUpload, - ImageDisplay + ImageUpload } } diff --git a/src/modules/chat/components/ChatComponent.vue b/src/modules/chat/components/ChatComponent.vue index 7e2c27a..f893e2d 100644 --- a/src/modules/chat/components/ChatComponent.vue +++ b/src/modules/chat/components/ChatComponent.vue @@ -415,22 +415,8 @@ const currentMessages = computed(() => { return chat.currentMessages.value }) -// Sort peers by unread count and name -const sortedPeers = computed(() => { - const sorted = [...peers.value].sort((a, b) => { - const aUnreadCount = getUnreadCount(a.pubkey) - const bUnreadCount = getUnreadCount(b.pubkey) - - // First, sort by unread count (peers with unread messages appear first) - if (aUnreadCount > 0 && bUnreadCount === 0) return -1 - if (aUnreadCount === 0 && bUnreadCount > 0) return 1 - - // Finally, sort alphabetically by name for peers with same unread status - return (a.name || '').localeCompare(b.name || '') - }) - - return sorted -}) +// NOTE: peers is already sorted correctly by the chat service (by activity: lastSent/lastReceived) +// We use it directly without re-sorting here // Fuzzy search for peers // This integrates the useFuzzySearch composable to provide intelligent search functionality @@ -441,7 +427,7 @@ const { isSearching, resultCount, clearSearch -} = useFuzzySearch(sortedPeers, { +} = useFuzzySearch(peers, { fuseOptions: { keys: [ { name: 'name', weight: 0.7 }, // Name has higher weight for better UX diff --git a/src/modules/chat/composables/useChat.ts b/src/modules/chat/composables/useChat.ts index c0a3136..6bc6578 100644 --- a/src/modules/chat/composables/useChat.ts +++ b/src/modules/chat/composables/useChat.ts @@ -60,8 +60,12 @@ export function useChat() { return chatService.addPeer(pubkey, name) } - const markAsRead = (peerPubkey: string) => { - chatService.markAsRead(peerPubkey) + const markAsRead = (peerPubkey: string, timestamp?: number) => { + chatService.markAsRead(peerPubkey, timestamp) + } + + const markAllChatsAsRead = () => { + chatService.markAllChatsAsRead() } const refreshPeers = async () => { @@ -81,19 +85,20 @@ export function useChat() { refreshPeersError: refreshPeersOp.error, isLoading: computed(() => asyncOps.isAnyLoading()), error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value), - + // Computed peers, totalUnreadCount, isReady, currentMessages, currentPeer, - + // Methods selectPeer, sendMessage, addPeer, markAsRead, + markAllChatsAsRead, refreshPeers } } \ No newline at end of file diff --git a/src/modules/chat/composables/useNotifications.ts b/src/modules/chat/composables/useNotifications.ts new file mode 100644 index 0000000..97f0fd7 --- /dev/null +++ b/src/modules/chat/composables/useNotifications.ts @@ -0,0 +1,77 @@ +import { computed } from 'vue' +import { useChatNotificationStore } from '../stores/notification' +import type { ChatMessage } from '../types' + +/** + * Composable for chat notification management + * + * Provides easy access to notification store functionality + * with computed properties and convenience methods. + */ +export function useChatNotifications() { + const notificationStore = useChatNotificationStore() + + /** + * Get unread count for a specific chat + */ + const getUnreadCount = (peerPubkey: string, messages: ChatMessage[]): number => { + return notificationStore.getUnreadCount(peerPubkey, messages) + } + + /** + * Check if a message has been seen + */ + const isMessageSeen = (peerPubkey: string, messageTimestamp: number): boolean => { + const path = `chat/${peerPubkey}` + return notificationStore.isSeen(path, messageTimestamp) + } + + /** + * Mark a specific chat as read + */ + const markChatAsRead = (peerPubkey: string, timestamp?: number): void => { + notificationStore.markChatAsRead(peerPubkey, timestamp) + } + + /** + * Mark all chats as read + */ + const markAllChatsAsRead = (): void => { + notificationStore.markAllChatsAsRead() + } + + /** + * Mark everything (all notifications) as read + */ + const markAllAsRead = (): void => { + notificationStore.markAllAsRead() + } + + /** + * Get the timestamp when a path was last marked as read + */ + const getSeenAt = (path: string, eventTimestamp: number): number => { + return notificationStore.getSeenAt(path, eventTimestamp) + } + + /** + * Clear all notification state + */ + const clearAllNotifications = (): void => { + notificationStore.clearAll() + } + + return { + // State + checked: computed(() => notificationStore.checked), + + // Methods + getUnreadCount, + isMessageSeen, + markChatAsRead, + markAllChatsAsRead, + markAllAsRead, + getSeenAt, + clearAllNotifications, + } +} diff --git a/src/modules/chat/index.ts b/src/modules/chat/index.ts index 831732a..b3ec669 100644 --- a/src/modules/chat/index.ts +++ b/src/modules/chat/index.ts @@ -26,8 +26,11 @@ export const chatModule: ModulePlugin = { maxMessages: 500, autoScroll: true, showTimestamps: true, - notificationsEnabled: true, - soundEnabled: false, + notifications: { + enabled: true, + soundEnabled: false, + wildcardSupport: true + }, ...options?.config } diff --git a/src/modules/chat/services/chat-service.ts b/src/modules/chat/services/chat-service.ts index 503ddc4..37cb14c 100644 --- a/src/modules/chat/services/chat-service.ts +++ b/src/modules/chat/services/chat-service.ts @@ -2,9 +2,10 @@ import { ref, computed } from 'vue' import { eventBus } from '@/core/event-bus' import { BaseService } from '@/core/base/BaseService' import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools' -import type { ChatMessage, ChatPeer, UnreadMessageData, ChatConfig } from '../types' +import type { ChatMessage, ChatPeer, ChatConfig } from '../types' import { getAuthToken } from '@/lib/config/lnbits' import { config } from '@/lib/config' +import { useChatNotificationStore } from '../stores/notification' export class ChatService extends BaseService { // Service metadata protected readonly metadata = { @@ -21,20 +22,35 @@ export class ChatService extends BaseService { private visibilityUnsubscribe?: () => void private isFullyInitialized = false private authCheckInterval?: ReturnType + private notificationStore?: ReturnType + constructor(config: ChatConfig) { super() this.config = config - this.loadPeersFromStorage() + // NOTE: DO NOT call loadPeersFromStorage() here - it depends on StorageService + // which may not be available yet. Moved to onInitialize(). } // Register market message handler for forwarding market-related DMs setMarketMessageHandler(handler: (event: any) => Promise) { this.marketMessageHandler = handler } + + /** + * Get the notification store, ensuring it's initialized + * CRITICAL: This must only be called after onInitialize() has run + */ + private getNotificationStore(): ReturnType { + if (!this.notificationStore) { + throw new Error('ChatService: Notification store not initialized yet. This should not happen after onInitialize().') + } + return this.notificationStore + } /** * Service-specific initialization (called by BaseService) */ protected async onInitialize(): Promise { this.debug('Chat service onInitialize called') + // Check both injected auth service AND global auth composable // Removed dual auth import const hasAuthService = this.authService?.user?.value?.pubkey @@ -83,6 +99,13 @@ export class ChatService extends BaseService { return } this.debug('Completing chat service initialization...') + + // CRITICAL: Initialize notification store AFTER user is authenticated + // StorageService needs user pubkey to scope the storage keys correctly + if (!this.notificationStore) { + this.notificationStore = useChatNotificationStore() + } + // Load peers from storage first this.loadPeersFromStorage() // Load peers from API @@ -106,12 +129,59 @@ export class ChatService extends BaseService { } // Computed properties get allPeers() { - return computed(() => Array.from(this.peers.value.values())) + return computed(() => { + const peers = Array.from(this.peers.value.values()) + + // Sort by last activity (Coracle pattern) + // Most recent conversation first + return peers.sort((a, b) => { + // Calculate activity from actual messages (source of truth) + const aMessages = this.getMessages(a.pubkey) + const bMessages = this.getMessages(b.pubkey) + + let aActivity = 0 + let bActivity = 0 + + // Get last message timestamp from actual messages + if (aMessages.length > 0) { + const lastMsg = aMessages[aMessages.length - 1] + aActivity = lastMsg.created_at + } else { + // Fallback to stored timestamps only if no messages + aActivity = Math.max(a.lastSent || 0, a.lastReceived || 0) + } + + if (bMessages.length > 0) { + const lastMsg = bMessages[bMessages.length - 1] + bActivity = lastMsg.created_at + } else { + // Fallback to stored timestamps only if no messages + bActivity = Math.max(b.lastSent || 0, b.lastReceived || 0) + } + + // Peers with activity always come before peers without activity + if (aActivity > 0 && bActivity === 0) return -1 + if (aActivity === 0 && bActivity > 0) return 1 + + // Primary sort: by activity timestamp (descending - most recent first) + if (bActivity !== aActivity) { + return bActivity - aActivity + } + + // Stable tiebreaker: sort by pubkey (prevents random reordering) + return a.pubkey.localeCompare(b.pubkey) + }) + }) } get totalUnreadCount() { return computed(() => { + if (!this.notificationStore) return 0 // Not initialized yet return Array.from(this.peers.value.values()) - .reduce((total, peer) => total + peer.unreadCount, 0) + .reduce((total, peer) => { + const messages = this.getMessages(peer.pubkey) + const unreadCount = this.getNotificationStore().getUnreadCount(peer.pubkey, messages) + return total + unreadCount + }, 0) }) } get isReady() { @@ -123,7 +193,13 @@ export class ChatService extends BaseService { } // Get peer by pubkey getPeer(pubkey: string): ChatPeer | undefined { - return this.peers.value.get(pubkey) + const peer = this.peers.value.get(pubkey) + if (peer && this.notificationStore) { + // Update unread count from notification store (only if store is initialized) + const messages = this.getMessages(pubkey) + peer.unreadCount = this.getNotificationStore().getUnreadCount(pubkey, messages) + } + return peer } // Add or update a peer addPeer(pubkey: string, name?: string): ChatPeer { @@ -133,7 +209,9 @@ export class ChatService extends BaseService { pubkey, name: name || `User ${pubkey.slice(0, 8)}`, unreadCount: 0, - lastSeen: Date.now() + lastSent: 0, + lastReceived: 0, + lastChecked: 0 } this.peers.value.set(pubkey, peer) this.savePeersToStorage() @@ -153,7 +231,7 @@ export class ChatService extends BaseService { // Avoid duplicates if (!peerMessages.some(m => m.id === message.id)) { peerMessages.push(message) - // Sort by timestamp + // Sort by timestamp (ascending - chronological order within conversation) peerMessages.sort((a, b) => a.created_at - b.created_at) // Limit message count if (peerMessages.length > this.config.maxMessages) { @@ -162,42 +240,95 @@ export class ChatService extends BaseService { // 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) + + // Update lastSent or lastReceived based on message direction (Coracle pattern) + if (message.sent) { + peer.lastSent = Math.max(peer.lastSent, message.created_at) + } else { + peer.lastReceived = Math.max(peer.lastReceived, message.created_at) } + + // Update unread count from notification store (only if store is initialized) + const messages = this.getMessages(peerPubkey) + const unreadCount = this.notificationStore + ? this.getNotificationStore().getUnreadCount(peerPubkey, messages) + : 0 + peer.unreadCount = unreadCount + + // Save updated peer data + this.savePeersToStorage() + // Emit events const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received' eventBus.emit(eventType, { message, peerPubkey }, 'chat-service') + + // Emit unread count change if message is not sent by us + if (!message.sent) { + eventBus.emit('chat:unread-count-changed', { + peerPubkey, + count: unreadCount, + totalUnread: this.totalUnreadCount.value + }, 'chat-service') + } } } // Mark messages as read for a peer - markAsRead(peerPubkey: string): void { + markAsRead(peerPubkey: string, timestamp?: number): 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() + if (peer) { + const ts = timestamp || Math.floor(Date.now() / 1000) + + // Update lastChecked timestamp (Coracle pattern) + const oldChecked = peer.lastChecked + peer.lastChecked = Math.max(peer.lastChecked, ts) + + // Use notification store to mark as read + this.getNotificationStore().markChatAsRead(peerPubkey, timestamp) + + // Update peer unread count + const messages = this.getMessages(peerPubkey) + const oldUnreadCount = peer.unreadCount + peer.unreadCount = this.getNotificationStore().getUnreadCount(peerPubkey, messages) + + // Only save if something actually changed (prevent unnecessary reactivity) + if (oldChecked !== peer.lastChecked || oldUnreadCount !== peer.unreadCount) { + this.savePeersToStorage() + } + + // Emit event only if unread count changed + if (oldUnreadCount !== peer.unreadCount) { + eventBus.emit('chat:unread-count-changed', { + peerPubkey, + count: peer.unreadCount, + totalUnread: this.totalUnreadCount.value + }, 'chat-service') } - this.saveUnreadData(peerPubkey, unreadData) - eventBus.emit('chat:unread-count-changed', { - peerPubkey, - count: 0, - totalUnread: this.totalUnreadCount.value - }, 'chat-service') } } + + // Mark all chats as read + markAllChatsAsRead(): void { + this.getNotificationStore().markAllChatsAsRead() + + // Update all peers' unread counts + Array.from(this.peers.value.values()).forEach(peer => { + const messages = this.getMessages(peer.pubkey) + peer.unreadCount = this.getNotificationStore().getUnreadCount(peer.pubkey, messages) + }) + + // Emit event + eventBus.emit('chat:unread-count-changed', { + peerPubkey: '*', + count: 0, + totalUnread: 0 + }, 'chat-service') + } // Refresh peers from API async refreshPeers(): Promise { // Check if we should trigger full initialization // Removed dual auth import const hasAuth = this.authService?.user?.value?.pubkey if (!this.isFullyInitialized && hasAuth) { - console.log('💬 Refresh peers triggered full initialization') await this.completeInitialization() } return this.loadPeersFromAPI() @@ -252,8 +383,7 @@ export class ChatService extends BaseService { // Add to local messages immediately this.addMessage(peerPubkey, message) // Publish to Nostr relays - const result = await relayHub.publishEvent(signedEvent) - console.log('Message published to relays:', { success: result.success, total: result.total }) + await relayHub.publishEvent(signedEvent) } catch (error) { console.error('Failed to send message:', error) throw error @@ -272,42 +402,6 @@ export class ChatService extends BaseService { return bytes } - 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 { - const data = this.storageService.getUserData(`chat-unread-messages-${peerPubkey}`, { - lastReadTimestamp: 0, - unreadCount: 0, - processedMessageIds: [] - }) - return { - ...data, - processedMessageIds: new Set(data.processedMessageIds || []) - } - } - private saveUnreadData(peerPubkey: string, data: UnreadMessageData): void { - const serializable = { - ...data, - processedMessageIds: Array.from(data.processedMessageIds) - } - this.storageService.setUserData(`chat-unread-messages-${peerPubkey}`, serializable) - } // Load peers from API async loadPeersFromAPI(): Promise { try { @@ -316,9 +410,15 @@ export class ChatService extends BaseService { console.warn('💬 No authentication token found for loading peers from API') throw new Error('No authentication token found') } + + // Get current user pubkey to exclude from peers + const currentUserPubkey = this.authService?.user?.value?.pubkey + if (!currentUserPubkey) { + console.warn('💬 No current user pubkey available') + } + const API_BASE_URL = config.api.baseUrl || 'http://localhost:5006' - console.log('💬 Loading peers from API:', `${API_BASE_URL}/api/v1/auth/nostr/pubkeys`) - const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, { + const response = await fetch(`${API_BASE_URL}/api/v1/auth/nostr/pubkeys`, { headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' @@ -330,7 +430,6 @@ export class ChatService extends BaseService { throw new Error(`Failed to load peers: ${response.status} - ${errorText}`) } const data = await response.json() - console.log('💬 API returned', data?.length || 0, 'peers') if (!Array.isArray(data)) { console.warn('💬 Invalid API response format - expected array, got:', typeof data) return @@ -341,17 +440,35 @@ export class ChatService extends BaseService { console.warn('💬 Skipping peer without pubkey:', peer) return } - const chatPeer: ChatPeer = { - pubkey: peer.pubkey, - name: peer.username || `User ${peer.pubkey.slice(0, 8)}`, - unreadCount: 0, - lastSeen: Date.now() + + // CRITICAL: Skip current user - you can't chat with yourself! + if (currentUserPubkey && peer.pubkey === currentUserPubkey) { + return + } + + // Check if peer already exists to preserve message history timestamps + const existingPeer = this.peers.value.get(peer.pubkey) + + if (existingPeer) { + // Update name only if provided + if (peer.username && peer.username !== existingPeer.name) { + existingPeer.name = peer.username + } + } else { + // Create new peer with all required fields + const chatPeer: ChatPeer = { + pubkey: peer.pubkey, + name: peer.username || `User ${peer.pubkey.slice(0, 8)}`, + unreadCount: 0, + lastSent: 0, + lastReceived: 0, + lastChecked: 0 + } + this.peers.value.set(peer.pubkey, chatPeer) } - this.peers.value.set(peer.pubkey, chatPeer) }) // Save to storage this.savePeersToStorage() - console.log(`✅ Loaded ${data.length} peers from API, total peers now: ${this.peers.value.size}`) } catch (error) { console.error('❌ Failed to load peers from API:', error) // Don't re-throw - peers from storage are still available @@ -366,9 +483,15 @@ export class ChatService extends BaseService { } try { const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[] - console.log('💬 Loading', peersArray.length, 'peers from storage') peersArray.forEach(peer => { - this.peers.value.set(peer.pubkey, peer) + // Migrate old peer structure to new structure with required fields + const migratedPeer: ChatPeer = { + ...peer, + lastSent: peer.lastSent ?? 0, + lastReceived: peer.lastReceived ?? 0, + lastChecked: peer.lastChecked ?? 0 + } + this.peers.value.set(peer.pubkey, migratedPeer) }) } catch (error) { console.warn('💬 Failed to load peers from storage:', error) @@ -396,10 +519,8 @@ export class ChatService extends BaseService { } const peerPubkeys = Array.from(this.peers.value.keys()) if (peerPubkeys.length === 0) { - console.log('No peers to load message history for') - return + return } - console.log('Loading message history for', peerPubkeys.length, 'peers') // Query historical messages (kind 4) to/from known peers // We need separate queries for sent vs received messages due to different tagging const receivedEvents = await this.relayHub.queryEvents([ @@ -412,7 +533,7 @@ export class ChatService extends BaseService { ]) const sentEvents = await this.relayHub.queryEvents([ { - kinds: [4], + kinds: [4], authors: [userPubkey], // Messages from us '#p': peerPubkeys, // Messages tagged to peers limit: 100 @@ -420,15 +541,36 @@ export class ChatService extends BaseService { ]) const events = [...receivedEvents, ...sentEvents] .sort((a, b) => a.created_at - b.created_at) // Sort by timestamp - console.log('Found', events.length, 'historical messages:', receivedEvents.length, 'received,', sentEvents.length, 'sent') + + // CRITICAL: First pass - create all peers from message events BEFORE loading from API + const uniquePeerPubkeys = new Set() + for (const event of events) { + const isFromUs = event.pubkey === userPubkey + const peerPubkey = isFromUs + ? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] + : event.pubkey + + if (peerPubkey && peerPubkey !== userPubkey) { + uniquePeerPubkeys.add(peerPubkey) + } + } + + // Create peers from actual message senders + for (const peerPubkey of uniquePeerPubkeys) { + if (!this.peers.value.has(peerPubkey)) { + this.addPeer(peerPubkey) + } + } + // Process historical messages for (const event of events) { try { const isFromUs = event.pubkey === userPubkey - const peerPubkey = isFromUs + const peerPubkey = isFromUs ? event.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag : event.pubkey // Sender is the peer if (!peerPubkey || peerPubkey === userPubkey) continue + // Decrypt the message const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content) // Create a chat message @@ -439,13 +581,13 @@ export class ChatService extends BaseService { sent: isFromUs, pubkey: event.pubkey } + // Add the message (will avoid duplicates) this.addMessage(peerPubkey, message) } catch (error) { console.error('Failed to decrypt historical message:', error) } } - console.log('Message history loaded successfully') } catch (error) { console.error('Failed to load message history:', error) } @@ -473,7 +615,6 @@ export class ChatService extends BaseService { console.warn('💬 RelayHub not connected, waiting for connection...') // Listen for connection event this.relayHub.on('connected', () => { - console.log('💬 RelayHub connected, setting up message subscription...') this.setupMessageSubscription() }) // Also retry after timeout in case event is missed @@ -502,10 +643,8 @@ export class ChatService extends BaseService { await this.processIncomingMessage(event) }, onEose: () => { - console.log('💬 Chat message subscription EOSE received') } }) - console.log('💬 Chat message subscription set up successfully for pubkey:', userPubkey.substring(0, 10) + '...') } catch (error) { console.error('💬 Failed to setup message subscription:', error) // Retry after delay @@ -590,7 +729,6 @@ export class ChatService extends BaseService { // Forward to market handler if (this.marketMessageHandler) { await this.marketMessageHandler(event) - console.log('💬 Market message forwarded to market handler and will also be added to chat') } else { console.warn('Market message handler not available, message will be treated as chat') } @@ -633,7 +771,6 @@ export class ChatService extends BaseService { this.addPeer(senderPubkey) // Add the message this.addMessage(senderPubkey, message) - console.log('Received encrypted chat message from:', senderPubkey.slice(0, 8)) } } catch (error) { console.error('Failed to process incoming message:', error) diff --git a/src/modules/chat/stores/notification.ts b/src/modules/chat/stores/notification.ts new file mode 100644 index 0000000..d0af785 --- /dev/null +++ b/src/modules/chat/stores/notification.ts @@ -0,0 +1,202 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import type { StorageService } from '@/core/services/StorageService' + +/** + * Chat Notification Store + * + * Implements Coracle-inspired path-based notification tracking with wildcard support. + * Uses timestamps instead of boolean flags for flexible "mark as read up to X time" behavior. + * + * Path patterns: + * - 'chat/*' - All chat notifications + * - 'chat/{pubkey}' - Specific chat conversation + * - '*' - Global mark all as read + */ + +const STORAGE_KEY = 'chat-notifications-checked' + +export const useChatNotificationStore = defineStore('chat-notifications', () => { + // Inject storage service for user-scoped persistence + const storageService = injectService(SERVICE_TOKENS.STORAGE_SERVICE) + + + // State: path -> timestamp mappings + const checked = ref>({}) + + /** + * Load notification state from storage + */ + const loadFromStorage = () => { + if (!storageService) { + console.warn('📢 Cannot load chat notifications: StorageService not available') + return + } + + const stored = storageService.getUserData>(STORAGE_KEY, {}) + checked.value = stored || {} + } + + // Debounce timer for storage writes + let saveDebounce: ReturnType | undefined + + /** + * Save notification state to storage (debounced) + */ + const saveToStorage = () => { + if (!storageService) return + + // Clear existing debounce timer + if (saveDebounce !== undefined) { + clearTimeout(saveDebounce) + } + + // Debounce writes by 2 seconds (Snort pattern) + saveDebounce = setTimeout(() => { + storageService.setUserData(STORAGE_KEY, checked.value) + saveDebounce = undefined + }, 2000) + } + + /** + * Get the "seen at" timestamp for a given path and event timestamp + * + * Implements Coracle's wildcard matching logic: + * 1. Check direct path match + * 2. Check wildcard pattern (e.g., 'chat/*' for 'chat/abc123') + * 3. Check global wildcard ('*') + * + * @param path - Notification path (e.g., 'chat/pubkey123') + * @param eventTimestamp - Timestamp of the event to check + * @returns The max timestamp if event has been seen, 0 otherwise + */ + const getSeenAt = (path: string, eventTimestamp: number): number => { + const directMatch = checked.value[path] || 0 + + // Extract wildcard pattern (e.g., 'chat/*' from 'chat/abc123') + const pathParts = path.split('/') + const wildcardMatch = pathParts.length > 1 + ? (checked.value[`${pathParts[0]}/*`] || 0) + : 0 + + const globalMatch = checked.value['*'] || 0 + + // Get maximum timestamp from all matches + const maxTimestamp = Math.max(directMatch, wildcardMatch, globalMatch) + + // Return maxTimestamp if event has been seen, 0 otherwise + return maxTimestamp >= eventTimestamp ? maxTimestamp : 0 + } + + /** + * Check if a message/event has been seen + * + * @param path - Notification path + * @param eventTimestamp - Event timestamp to check + * @returns True if the event has been marked as read + */ + const isSeen = (path: string, eventTimestamp: number): boolean => { + return getSeenAt(path, eventTimestamp) > 0 + } + + /** + * Mark a path as checked/read + * + * @param path - Notification path to mark as read + * @param timestamp - Optional timestamp (defaults to now) + */ + const setChecked = (path: string, timestamp?: number) => { + const ts = timestamp || Math.floor(Date.now() / 1000) + checked.value[path] = ts + saveToStorage() + } + + /** + * Mark all chat messages as read + */ + const markAllChatsAsRead = () => { + setChecked('chat/*') + } + + /** + * Mark a specific chat conversation as read + * + * @param peerPubkey - Pubkey of the chat peer + * @param timestamp - Optional timestamp (defaults to now) + */ + const markChatAsRead = (peerPubkey: string, timestamp?: number) => { + setChecked(`chat/${peerPubkey}`, timestamp) + } + + /** + * Mark everything as read (global) + */ + const markAllAsRead = () => { + setChecked('*') + } + + /** + * Get unread count for a specific chat + * + * @param peerPubkey - Pubkey of the chat peer + * @param messages - Array of chat messages with created_at timestamps + * @returns Number of unread messages + */ + const getUnreadCount = (peerPubkey: string, messages: Array<{ created_at: number; sent: boolean }>): number => { + const path = `chat/${peerPubkey}` + const receivedMessages = messages.filter(msg => !msg.sent) + const unseenMessages = receivedMessages.filter(msg => !isSeen(path, msg.created_at)) + + return unseenMessages.length + } + + /** + * Force immediate save (for critical operations or before unload) + */ + const saveImmediately = () => { + if (!storageService) return + + // Cancel any pending debounced save + if (saveDebounce !== undefined) { + clearTimeout(saveDebounce) + saveDebounce = undefined + } + + // Save immediately + storageService.setUserData(STORAGE_KEY, checked.value) + } + + /** + * Clear all notification state + */ + const clearAll = () => { + checked.value = {} + saveImmediately() // Clear immediately + } + + // Initialize from storage + loadFromStorage() + + // Save immediately before page unload (ensure no data loss) + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', saveImmediately) + } + + return { + // State + checked: computed(() => checked.value), + + // Methods + getSeenAt, + isSeen, + setChecked, + markAllChatsAsRead, + markChatAsRead, + markAllAsRead, + getUnreadCount, + clearAll, + saveImmediately, + loadFromStorage, // Export for explicit reload if needed + } +}) diff --git a/src/modules/chat/types/index.ts b/src/modules/chat/types/index.ts index 37b23fa..404747a 100644 --- a/src/modules/chat/types/index.ts +++ b/src/modules/chat/types/index.ts @@ -13,7 +13,9 @@ export interface ChatPeer { name?: string lastMessage?: ChatMessage unreadCount: number - lastSeen: number + lastSent: number // Timestamp of last message YOU sent + lastReceived: number // Timestamp of last message you RECEIVED + lastChecked: number // Timestamp when you last viewed the conversation } export interface NostrRelayConfig { @@ -22,18 +24,17 @@ export interface NostrRelayConfig { write?: boolean } -export interface UnreadMessageData { - lastReadTimestamp: number - unreadCount: number - processedMessageIds: Set +export interface ChatNotificationConfig { + enabled: boolean + soundEnabled: boolean + wildcardSupport: boolean } export interface ChatConfig { maxMessages: number autoScroll: boolean showTimestamps: boolean - notificationsEnabled: boolean - soundEnabled: boolean + notifications?: ChatNotificationConfig } // Events emitted by chat module diff --git a/src/modules/market/components/CreateProductDialog.vue b/src/modules/market/components/CreateProductDialog.vue index 93d6042..dd5b505 100644 --- a/src/modules/market/components/CreateProductDialog.vue +++ b/src/modules/market/components/CreateProductDialog.vue @@ -127,11 +127,17 @@ Product Images - Add images to showcase your product -
- -

Image upload coming soon

-
+ Add up to 5 images to showcase your product. The first image will be the primary display image. +
@@ -218,12 +224,13 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' -import { Package } from 'lucide-vue-next' import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI' import type { Product } from '../types/market' import { auth } from '@/composables/useAuthService' import { useToast } from '@/core/composables/useToast' import { injectService, SERVICE_TOKENS } from '@/core/di-container' +import ImageUpload from '@/modules/base/components/ImageUpload.vue' +import type { ImageUploadService } from '@/modules/base/services/ImageUploadService' // Props and emits interface Props { @@ -242,11 +249,13 @@ const emit = defineEmits<{ // Services const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any +const imageService = injectService(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE) const toast = useToast() // Local state const isCreating = ref(false) const createError = ref(null) +const uploadedImages = ref([]) // Track uploaded images with their metadata // Computed properties const isEditMode = computed(() => !!props.product?.id) @@ -311,18 +320,31 @@ const updateProduct = async (formData: any) => { return } - const { - name, - description, - price, - quantity, - categories, - images, - active, - use_autoreply, - autoreply_message + const { + name, + description, + price, + quantity, + categories, + active, + use_autoreply, + autoreply_message } = formData + // Get uploaded image URLs from the image service + const images: string[] = [] + if (uploadedImages.value && uploadedImages.value.length > 0) { + for (const img of uploadedImages.value) { + if (img.alias) { + // Get the full URL for the image + const imageUrl = imageService.getImageUrl(img.alias) + if (imageUrl) { + images.push(imageUrl) + } + } + } + } + isCreating.value = true createError.value = null @@ -382,18 +404,31 @@ const createProduct = async (formData: any) => { return } - const { - name, - description, - price, - quantity, - categories, - images, - active, - use_autoreply, - autoreply_message + const { + name, + description, + price, + quantity, + categories, + active, + use_autoreply, + autoreply_message } = formData + // Get uploaded image URLs from the image service + const images: string[] = [] + if (uploadedImages.value && uploadedImages.value.length > 0) { + for (const img of uploadedImages.value) { + if (img.alias) { + // Get the full URL for the image + const imageUrl = imageService.getImageUrl(img.alias) + if (imageUrl) { + images.push(imageUrl) + } + } + } + } + isCreating.value = true createError.value = null @@ -420,6 +455,17 @@ const createProduct = async (formData: any) => { throw new Error('No wallet admin key available') } + // Debug: Log what we're sending + console.log('🛒 CreateProductDialog: About to create product with categories:', { + name: productData.name, + categories: productData.categories, + categoriesType: typeof productData.categories, + categoriesLength: productData.categories?.length, + formCategories: categories, + formData: formData, + fullProductData: productData + }) + const newProduct = await nostrmarketAPI.createProduct( adminKey, productData @@ -470,6 +516,34 @@ watch(() => props.isOpen, async (isOpen) => { // Reset form with appropriate initial values resetForm({ values: initialValues }) + // Convert existing image URLs to the format expected by ImageUpload component + if (props.product?.images && props.product.images.length > 0) { + // For existing products, we need to convert URLs back to a format ImageUpload can display + uploadedImages.value = props.product.images.map((url, index) => { + let alias = url + + // If it's a full pict-rs URL, extract just the file ID + if (url.includes('/image/original/')) { + const parts = url.split('/image/original/') + if (parts.length > 1 && parts[1]) { + alias = parts[1] + } + } else if (url.startsWith('http://') || url.startsWith('https://')) { + // Keep full URLs as-is + alias = url + } + + return { + alias: alias, + delete_token: '', + isPrimary: index === 0, + details: {} + } + }) + } else { + uploadedImages.value = [] + } + // Wait for reactivity await nextTick() diff --git a/src/modules/market/components/DashboardOverview.vue b/src/modules/market/components/DashboardOverview.vue index bfe1b73..103c07a 100644 --- a/src/modules/market/components/DashboardOverview.vue +++ b/src/modules/market/components/DashboardOverview.vue @@ -215,69 +215,107 @@ diff --git a/src/modules/market/components/DeleteConfirmDialog.vue b/src/modules/market/components/DeleteConfirmDialog.vue new file mode 100644 index 0000000..7bc2065 --- /dev/null +++ b/src/modules/market/components/DeleteConfirmDialog.vue @@ -0,0 +1,85 @@ + + + \ No newline at end of file diff --git a/src/modules/market/components/MerchantOrders.vue b/src/modules/market/components/MerchantOrders.vue new file mode 100644 index 0000000..f4428a8 --- /dev/null +++ b/src/modules/market/components/MerchantOrders.vue @@ -0,0 +1,744 @@ + + + \ No newline at end of file diff --git a/src/modules/market/components/MerchantStore.vue b/src/modules/market/components/MerchantStore.vue index 2ded586..50da407 100644 --- a/src/modules/market/components/MerchantStore.vue +++ b/src/modules/market/components/MerchantStore.vue @@ -207,15 +207,23 @@
- +
-
-

Products

- -
+ + + Products + Orders + + + + +
+

Products

+ +
@@ -276,6 +284,19 @@ {{ product.active ? 'Active' : 'Inactive' }} + + +
+ + + +
@@ -292,18 +313,54 @@ -
- + +
+ + + + + + + + @@ -325,6 +382,16 @@ @created="onProductCreated" @updated="onProductUpdated" /> + + + @@ -334,15 +401,27 @@ import { useRouter } from 'vue-router' import { useMarketStore } from '@/modules/market/stores/market' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui/tabs' import { Package, Store, DollarSign, Star, Plus, - User + User, + Trash2, + Send, + Edit, + CheckCircle, + Clock, + AlertCircle } from 'lucide-vue-next' -import type { NostrmarketAPI, Merchant, Stall } from '../services/nostrmarketAPI' +import type { NostrmarketAPI, Merchant, Stall, ProductApiResponse } from '../services/nostrmarketAPI' import type { Product } from '../types/market' import { mapApiResponseToProduct } from '../types/market' import { auth } from '@/composables/useAuthService' @@ -350,7 +429,9 @@ import { useToast } from '@/core/composables/useToast' import { injectService, SERVICE_TOKENS } from '@/core/di-container' import CreateStoreDialog from './CreateStoreDialog.vue' import CreateProductDialog from './CreateProductDialog.vue' +import DeleteConfirmDialog from './DeleteConfirmDialog.vue' import StoreCard from './StoreCard.vue' +import MerchantOrders from './MerchantOrders.vue' const router = useRouter() const marketStore = useMarketStore() @@ -376,10 +457,25 @@ const activeStall = computed(() => const stallProducts = ref([]) const isLoadingProducts = ref(false) +// Product action state +const isDeletingProduct = ref(false) +const deletingProductId = ref(null) +const isResendingProduct = ref(false) +const resendingProductId = ref(null) + +// Nostr sync tracking +const pendingNostrConfirmation = ref>(new Map()) // productId -> timestamp +const confirmedOnNostr = ref>(new Set()) + +// Tab management +const activeTab = ref('products') + // Dialog state const showCreateStoreDialog = ref(false) const showCreateProductDialog = ref(false) +const showDeleteConfirmDialog = ref(false) const editingProduct = ref(null) +const productToDelete = ref(null) // Computed properties const userHasMerchantProfile = computed(() => { @@ -390,6 +486,17 @@ const userHasStalls = computed(() => { return userStalls.value.length > 0 }) +// Helper to get sync status for a product +const getProductSyncStatus = (productId: string) => { + if (confirmedOnNostr.value.has(productId)) { + return 'confirmed' + } + if (pendingNostrConfirmation.value.has(productId)) { + return 'pending' + } + return 'unknown' +} + const storeStats = computed(() => { const currentUserPubkey = auth.currentUser?.value?.pubkey if (!currentUserPubkey) { @@ -534,6 +641,9 @@ const loadStallProducts = async () => { .forEach(product => { marketStore.addProduct(product) }) + + // Initialize sync status for loaded products + initializeSyncStatus() } catch (error) { console.error('Failed to load products:', error) stallProducts.value = [] @@ -605,11 +715,248 @@ const editProduct = (product: Product) => { showCreateProductDialog.value = true } +const deleteProduct = (product: Product) => { + productToDelete.value = product + showDeleteConfirmDialog.value = true +} + +const confirmDeleteProduct = async () => { + if (!productToDelete.value) return + + const product = productToDelete.value + + try { + isDeletingProduct.value = true + deletingProductId.value = product.id + + const adminKey = paymentService.getPreferredWalletAdminKey() + if (!adminKey) { + throw new Error('No wallet admin key available') + } + + await nostrmarketAPI.deleteProduct(adminKey, product.id) + + // Remove from local state + stallProducts.value = stallProducts.value.filter(p => p.id !== product.id) + + showDeleteConfirmDialog.value = false + productToDelete.value = null + toast.success(`Product "${product.name}" deleted successfully!`) + } catch (error) { + console.error('Failed to delete product:', error) + toast.error('Failed to delete product. Please try again.') + } finally { + isDeletingProduct.value = false + deletingProductId.value = null + } +} + +const cancelDeleteProduct = () => { + showDeleteConfirmDialog.value = false + productToDelete.value = null +} + +const resendProduct = async (product: Product) => { + try { + isResendingProduct.value = true + resendingProductId.value = product.id + + const adminKey = paymentService.getPreferredWalletAdminKey() + if (!adminKey) { + throw new Error('No wallet admin key available') + } + + // Re-send by updating the product with its current data + // This will trigger LNbits to re-publish to Nostr + const productData: ProductApiResponse = { + id: product.id, + stall_id: product.stall_id, + name: product.name, + categories: product.categories || [], + images: product.images || [], + price: product.price, + quantity: product.quantity, + active: product.active ?? true, + pending: product.pending ?? false, + config: { + description: product.description || '', + currency: product.currency || 'sat', + use_autoreply: false, + autoreply_message: '', + shipping: [] + }, + event_id: product.nostrEventId, + event_created_at: product.createdAt + } + + await nostrmarketAPI.updateProduct(adminKey, product.id, productData) + + // Reset sync status - remove from confirmed and add to pending + confirmedOnNostr.value.delete(product.id) + pendingNostrConfirmation.value.set(product.id, Date.now()) + + console.log('🔄 Product re-sent - sync status reset to pending:', { + productId: product.id, + productName: product.name, + wasConfirmed: confirmedOnNostr.value.has(product.id), + nowPending: pendingNostrConfirmation.value.has(product.id) + }) + + toast.success(`Product "${product.name}" re-sent to LNbits for event publishing!`) + + // TODO: Consider adding a timeout to remove from pending if not confirmed within reasonable time + // (e.g., 30 seconds) to avoid keeping products in pending state indefinitely + } catch (error) { + console.error('Failed to re-send product:', error) + toast.error('Failed to re-send product. Please try again.') + } finally { + isResendingProduct.value = false + resendingProductId.value = null + } +} + const closeProductDialog = () => { showCreateProductDialog.value = false editingProduct.value = null } +// Watch for market store updates to detect confirmed products +watch(() => marketStore.products, (newProducts) => { + // Check if any pending products now appear in the market feed + for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) { + const foundProduct = newProducts.find(p => p.id === productId) + + if (foundProduct) { + // Find the corresponding local product to compare content + const localProduct = stallProducts.value.find(p => p.id === productId) + + if (localProduct) { + // Compare content to verify true sync + const localData = normalizeProductForComparison(localProduct) + const marketData = normalizeProductForComparison(foundProduct) + const localJson = JSON.stringify(localData) + const marketJson = JSON.stringify(marketData) + const isContentSynced = localJson === marketJson + + + if (isContentSynced) { + // Product content confirmed as synced on Nostr! + pendingNostrConfirmation.value.delete(productId) + confirmedOnNostr.value.add(productId) + + // Show confirmation toast + toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`) + + console.log('🎉 Product confirmed on Nostr with matching content:', { + productId, + productName: foundProduct.name, + pendingTime: Date.now() - timestamp, + contentVerified: true + }) + } else { + console.warn('⚠️ Product appeared in market but content differs:', { + productId, + productName: foundProduct.name, + localData, + marketData + }) + // Remove from pending - content doesn't match, so it's not properly synced + pendingNostrConfirmation.value.delete(productId) + // Don't add to confirmedOnNostr - it should show as unsynced + } + } else { + // No local product found - just mark as confirmed + pendingNostrConfirmation.value.delete(productId) + confirmedOnNostr.value.add(productId) + toast.success(`Product "${foundProduct.name}" successfully published to Nostr!`) + } + } + } + + // Update sync status for any new products that appear in market feed + initializeSyncStatus() +}, { deep: true }) + +// Cleanup pending confirmations after timeout (30 seconds) +const cleanupPendingConfirmations = () => { + const timeout = 30 * 1000 // 30 seconds + const now = Date.now() + + for (const [productId, timestamp] of pendingNostrConfirmation.value.entries()) { + if (now - timestamp > timeout) { + pendingNostrConfirmation.value.delete(productId) + console.warn('⏰ Timeout: Product confirmation removed from pending after 30s:', productId) + } + } +} + +// Run cleanup every 10 seconds +setInterval(cleanupPendingConfirmations, 10 * 1000) + +// Helper function to normalize product data for comparison +const normalizeProductForComparison = (product: any) => { + return { + name: product.name, + description: product.description || '', + price: product.price, + quantity: product.quantity, + active: product.active ?? true, + categories: (product.categories ? [...product.categories] : []).sort(), // Sort for consistent comparison + images: (product.images ? [...product.images] : []).sort(), // Sort for consistent comparison + currency: product.currency || 'sat' + } +} + +// Enhanced sync status detection with JSON content comparison +const initializeSyncStatus = () => { + // Cross-reference stallProducts with market feed to detect already-synced products + for (const product of stallProducts.value) { + if (product.id) { + const foundInMarket = marketStore.products.find(p => p.id === product.id) + if (foundInMarket) { + // Compare the actual product content, not just IDs + const localData = normalizeProductForComparison(product) + const marketData = normalizeProductForComparison(foundInMarket) + + // Deep comparison of normalized data + const localJson = JSON.stringify(localData) + const marketJson = JSON.stringify(marketData) + const isContentSynced = localJson === marketJson + + if (isContentSynced) { + // Product content is truly synced - mark as confirmed + confirmedOnNostr.value.add(product.id) + console.log('✅ Product content verified as synced to Nostr:', { + productId: product.id, + productName: product.name + }) + } else { + // Product exists but content differs - needs re-sync + console.warn('⚠️ Product exists but content differs - needs re-sync:', { + productId: product.id, + productName: product.name, + localData, + marketData, + differences: { + local: localData, + market: marketData + } + }) + // Remove from both confirmed and pending - it's out of sync + confirmedOnNostr.value.delete(product.id) + pendingNostrConfirmation.value.delete(product.id) + // User should see unsynced indicator (no badge) + } + } else { + console.log('📤 Product not found in market feed - not synced:', { + productId: product.id, + productName: product.name + }) + } + } + } +} + // Lifecycle onMounted(async () => { console.log('Merchant Store component loaded') diff --git a/src/modules/market/components/ProductCard.vue b/src/modules/market/components/ProductCard.vue index c570930..be865eb 100644 --- a/src/modules/market/components/ProductCard.vue +++ b/src/modules/market/components/ProductCard.vue @@ -1,11 +1,11 @@ \ No newline at end of file diff --git a/src/modules/market/composables/useMarket.ts b/src/modules/market/composables/useMarket.ts index a25d827..cadb14b 100644 --- a/src/modules/market/composables/useMarket.ts +++ b/src/modules/market/composables/useMarket.ts @@ -297,6 +297,14 @@ export function useMarket() { .map((tag: any) => tag[1]) .filter((cat: string) => cat && cat.trim()) + // Debug: Log category processing (when categories are present) + if (categories.length > 0) { + console.log('🛒 useMarket: Processing product with categories:', { + productName: productData.name, + processedCategories: categories, + eventTags: latestEvent.tags.filter((tag: string[]) => tag[0] === 't') + }) + } // Look up the stall name from the stalls array const stall = marketStore.stalls.find(s => s.id === stallId) @@ -310,7 +318,7 @@ export function useMarket() { description: productData.description || '', price: productData.price || 0, currency: productData.currency || 'sats', - quantity: productData.quantity || 1, + quantity: productData.quantity ?? 1, // Use nullish coalescing to preserve 0 images: productData.images || [], categories: categories, createdAt: latestEvent.created_at, @@ -489,6 +497,15 @@ export function useMarket() { .map((tag: any) => tag[1]) .filter((cat: string) => cat && cat.trim()) + // Debug: Log real-time category processing (when categories are present) + if (categories.length > 0) { + console.log('🛒 useMarket: Real-time product with categories:', { + productName: productData.name, + processedCategories: categories, + eventTags: event.tags.filter((tag: string[]) => tag[0] === 't') + }) + } + // Look up the stall name from the stalls array const stall = marketStore.stalls.find(s => s.id === stallId) const stallName = stall?.name || 'Unknown Stall' @@ -502,7 +519,7 @@ export function useMarket() { description: productData.description || '', price: productData.price || 0, currency: productData.currency || 'sats', - quantity: productData.quantity || 1, + quantity: productData.quantity ?? 1, // Use nullish coalescing to preserve 0 images: productData.images || [], categories: categories, createdAt: event.created_at, @@ -516,17 +533,7 @@ export function useMarket() { } } - // 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 - } + // Publishing methods removed - now handled by LNbits API endpoints // Connect to market const connectToMarket = async () => { @@ -617,8 +624,6 @@ export function useMarket() { connectToMarket, disconnectFromMarket, processPendingProducts, - publishProduct, - publishStall, subscribeToMarketUpdates, subscribeToOrderUpdates } diff --git a/src/modules/market/index.ts b/src/modules/market/index.ts index 93b6bd6..cea3db1 100644 --- a/src/modules/market/index.ts +++ b/src/modules/market/index.ts @@ -154,6 +154,15 @@ export const marketModule: ModulePlugin = { title: 'Stall', requiresAuth: false } + }, + { + path: '/market/product/:productId', + name: 'product-detail', + component: () => import('./views/ProductDetailPage.vue'), + meta: { + title: 'Product Details', + requiresAuth: false + } } ] as RouteRecordRaw[], diff --git a/src/modules/market/services/nostrmarketAPI.ts b/src/modules/market/services/nostrmarketAPI.ts index f4cae9f..4913aa0 100644 --- a/src/modules/market/services/nostrmarketAPI.ts +++ b/src/modules/market/services/nostrmarketAPI.ts @@ -99,6 +99,62 @@ export interface CreateStallRequest { } } +// Order related types +export interface OrderItem { + product_id: string + quantity: number +} + +export interface OrderContact { + nostr?: string + phone?: string + email?: string +} + +export interface ProductOverview { + id: string + name: string + price: number + product_shipping_cost?: number +} + +export interface OrderExtra { + products: ProductOverview[] + currency: string + btc_price: string + shipping_cost: number + shipping_cost_sat: number + fail_message?: string +} + +export interface OrderApiResponse { + id: string + event_id?: string + event_created_at?: number + public_key: string + stall_id: string + invoice_id: string + total: number + paid: boolean + shipped: boolean + time?: number + contact_data: string // JSON string + order_items: string // JSON string + extra_data: string // JSON string + address?: string + message?: string + contact?: OrderContact // Parsed from contact_data + items?: OrderItem[] // Parsed from order_items + extra?: OrderExtra // Parsed from extra_data +} + +export interface OrderStatusUpdate { + id: string + message?: string + paid?: boolean + shipped?: boolean +} + export class NostrmarketAPI extends BaseService { // Service metadata protected readonly metadata = { @@ -368,6 +424,20 @@ export class NostrmarketAPI extends BaseService { walletAdminkey: string, productData: CreateProductRequest ): Promise { + // Debug: Log the exact payload being sent + this.debug('Creating product with payload:', { + name: productData.name, + stall_id: productData.stall_id, + categories: productData.categories, + categoriesType: typeof productData.categories, + categoriesLength: productData.categories?.length, + price: productData.price, + quantity: productData.quantity, + active: productData.active, + config: productData.config, + fullPayload: JSON.stringify(productData, null, 2) + }) + const product = await this.request( '/api/v1/product', walletAdminkey, @@ -377,10 +447,12 @@ export class NostrmarketAPI extends BaseService { } ) - this.debug('Created product:', { + this.debug('Created product response:', { productId: product.id, productName: product.name, - stallId: product.stall_id + stallId: product.stall_id, + returnedCategories: product.categories, + returnedCategoriesLength: product.categories?.length }) return product @@ -446,4 +518,153 @@ export class NostrmarketAPI extends BaseService { this.debug('Deleted product:', { productId }) } + + /** + * Get all orders for the merchant + */ + async getOrders( + walletInkey: string, + filters?: { paid?: boolean, shipped?: boolean, pubkey?: string } + ): Promise { + try { + const params = new URLSearchParams() + if (filters?.paid !== undefined) params.append('paid', filters.paid.toString()) + if (filters?.shipped !== undefined) params.append('shipped', filters.shipped.toString()) + if (filters?.pubkey) params.append('pubkey', filters.pubkey) + + const queryString = params.toString() + const endpoint = queryString ? `/api/v1/order?${queryString}` : '/api/v1/order' + + const orders = await this.request( + endpoint, + walletInkey, + { method: 'GET' } + ) + + // The API already returns parsed objects, no need to parse JSON strings + const parsedOrders = (orders || []).map((order, index) => { + // Debug: Log the first order's structure + if (index === 0) { + this.debug('First order structure:', { + id: order.id, + contact: order.contact, + items: order.items, + extra: order.extra, + hasContactData: !!order.contact, + hasItemsData: !!order.items, + hasExtraData: !!order.extra, + hasProductsInExtra: !!(order.extra?.products) + }) + } + + return order + }) + + this.debug('Retrieved orders:', { count: parsedOrders.length, filters }) + return parsedOrders + } catch (error) { + this.debug('Failed to get orders:', error) + return [] + } + } + + /** + * Get orders for a specific stall + */ + async getStallOrders( + walletInkey: string, + stallId: string, + filters?: { paid?: boolean, shipped?: boolean, pubkey?: string } + ): Promise { + try { + const params = new URLSearchParams() + if (filters?.paid !== undefined) params.append('paid', filters.paid.toString()) + if (filters?.shipped !== undefined) params.append('shipped', filters.shipped.toString()) + if (filters?.pubkey) params.append('pubkey', filters.pubkey) + + const queryString = params.toString() + const endpoint = queryString + ? `/api/v1/stall/order/${stallId}?${queryString}` + : `/api/v1/stall/order/${stallId}` + + const orders = await this.request( + endpoint, + walletInkey, + { method: 'GET' } + ) + + // The API already returns parsed objects, no need to parse JSON strings + const parsedOrders = (orders || []).map((order, index) => { + // Debug: Log the first order's structure for stall orders too + if (index === 0) { + this.debug('First stall order structure:', { + id: order.id, + contact: order.contact, + items: order.items, + extra: order.extra, + hasContactData: !!order.contact, + hasItemsData: !!order.items, + hasExtraData: !!order.extra, + hasProductsInExtra: !!(order.extra?.products) + }) + } + + return order + }) + + this.debug('Retrieved stall orders:', { stallId, count: parsedOrders.length, filters }) + return parsedOrders + } catch (error) { + this.debug('Failed to get stall orders:', error) + return [] + } + } + + /** + * Get a single order by ID + */ + async getOrder(walletInkey: string, orderId: string): Promise { + try { + const order = await this.request( + `/api/v1/order/${orderId}`, + walletInkey, + { method: 'GET' } + ) + + // The API already returns parsed objects, no parsing needed + + this.debug('Retrieved order:', { orderId }) + return order + } catch (error) { + this.debug('Failed to get order:', error) + return null + } + } + + /** + * Update order status (mark as paid/shipped) + */ + async updateOrderStatus( + walletAdminkey: string, + statusUpdate: OrderStatusUpdate + ): Promise { + try { + const order = await this.request( + `/api/v1/order/${statusUpdate.id}`, + walletAdminkey, + { + method: 'PATCH', + body: JSON.stringify(statusUpdate) + } + ) + + // The API already returns parsed objects, no parsing needed + + this.debug('Updated order status:', statusUpdate) + return order + } catch (error) { + this.debug('Failed to update order status:', error) + return null + } + } } diff --git a/src/modules/market/services/nostrmarketService.ts b/src/modules/market/services/nostrmarketService.ts index 7606e6b..733879e 100644 --- a/src/modules/market/services/nostrmarketService.ts +++ b/src/modules/market/services/nostrmarketService.ts @@ -1,6 +1,6 @@ import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools' import { BaseService } from '@/core/base/BaseService' -import type { Stall, Product, Order } from '@/modules/market/stores/market' +import type { Order } from '@/modules/market/stores/market' export interface NostrmarketStall { id: string @@ -27,6 +27,9 @@ export interface NostrmarketProduct { currency: string } +// Note: Stall and Product publishing is handled by LNbits API endpoints +// NostrmarketService now only handles order DMs and status updates + export interface NostrmarketOrder { id: string items: Array<{ @@ -152,90 +155,8 @@ export class NostrmarketService extends BaseService { } } - /** - * 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 this.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 this.relayHub.publishEvent(event) - - console.log('Product published to nostrmarket:', { - productId: product.id, - eventId: result, - content: productData - }) - - return result.success.toString() - } + // Removed publishStall() and publishProduct() methods + // Stall and product publishing is now handled by LNbits API endpoints /** * Publish an order event (kind 4 encrypted DM) to nostrmarket @@ -471,38 +392,6 @@ export class NostrmarketService extends BaseService { } } - /** - * 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 - } + // Removed publishMerchantCatalog() method + // Publishing is now handled by LNbits API endpoints } diff --git a/src/modules/market/stores/market.ts b/src/modules/market/stores/market.ts index 20c89f5..b1a11ba 100644 --- a/src/modules/market/stores/market.ts +++ b/src/modules/market/stores/market.ts @@ -470,51 +470,8 @@ export const useMarketStore = defineStore('market', () => { } } - // 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 - } - } + // Removed publishToNostrmarket() method + // Publishing is now handled automatically by LNbits API endpoints // Invoice management methods const createLightningInvoice = async (orderId: string, adminKey: string): Promise => { @@ -916,6 +873,5 @@ export const useMarketStore = defineStore('market', () => { saveOrdersToStorage, loadOrdersFromStorage, clearOrdersForUserChange, - publishToNostrmarket } }) \ No newline at end of file diff --git a/src/modules/market/views/CheckoutPage.vue b/src/modules/market/views/CheckoutPage.vue index e8890b4..a5cbdb7 100644 --- a/src/modules/market/views/CheckoutPage.vue +++ b/src/modules/market/views/CheckoutPage.vue @@ -290,7 +290,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Textarea } from '@/components/ui/textarea' import { Label } from '@/components/ui/label' -import ProgressiveImage from '@/components/ui/ProgressiveImage.vue' +import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue' import { Package, CheckCircle diff --git a/src/modules/market/views/MarketPage.vue b/src/modules/market/views/MarketPage.vue index 8d4e15d..b4ca8e2 100644 --- a/src/modules/market/views/MarketPage.vue +++ b/src/modules/market/views/MarketPage.vue @@ -238,7 +238,6 @@ const addToCart = (product: Product, quantity?: number) => { marketStore.addToStallCart(product, quantity || 1) } - const viewStall = (stallId: string) => { // Navigate to the stall view page router.push(`/market/stall/${stallId}`) diff --git a/src/modules/market/views/ProductDetailPage.vue b/src/modules/market/views/ProductDetailPage.vue new file mode 100644 index 0000000..b731b73 --- /dev/null +++ b/src/modules/market/views/ProductDetailPage.vue @@ -0,0 +1,314 @@ + + + + + \ No newline at end of file