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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ lightbox.currentIndex.value + 1 }} / {{ lightbox.totalImages.value }}
+
+
+
+
+
+ Use ← → arrow keys or swipe to navigate • ESC to close
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+
+
+ {{ currentImageIndex + 1 }} of {{ images.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
-
-
-
-
-
-
- 1 of {{ images.length }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ 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 @@
+
+
+
+
+
+
+
+
+
+ Delete Product
+
+
+ Are you sure you want to delete "{{ productName }}" ? This action cannot be undone and will remove the product from your store.
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ {{ isDeleting ? 'Deleting...' : 'Delete Product' }}
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+
+ Total Orders
+
+
+
+ {{ orderStats.totalOrders }}
+
+ {{ orderStats.paidOrders }} paid ,
+ {{ orderStats.unpaidOrders }} pending
+
+
+
+
+
+
+ Total Revenue
+
+
+
+
+
+ {{ formatSats(orderStats.totalRevenue) }}
+
+ {{ formatSats(orderStats.pendingRevenue) }} pending
+
+
+
+
+
+
+ Pending Payment
+
+
+
+
+
+ {{ orderStats.unpaidOrders }}
+
+ {{ formatSats(orderStats.pendingRevenue) }} sats total
+
+
+
+
+
+
+ To Ship
+
+
+
+
+
+ {{ orderStats.toShipOrders }}
+
+ Paid but not shipped
+
+
+
+
+
+
+
+
+
+
Orders
+
+
+
+ Refresh
+
+
+
+
+
+
+ All Orders
+ Unpaid
+ Paid
+ Shipped
+
+
+
+
+
+
+
+
+
+
+
Loading orders...
+
+
+
+
+
No orders found
+
Orders will appear here when customers place them
+
+
+
+
+
+
+
+ Order ID
+ Customer
+ Items
+ Total
+ Status
+ Date
+ Actions
+
+
+
+
+
+ {{ order.id.slice(-8) }}
+
+
+
+ {{ getCustomerDisplay(order) }}
+
+
+
+
+ {{ getItemsDisplay(order) }}
+
+
+
+
+
+ {{ formatSats(order.total) }}
+
+
+
+
+
+
+ {{ getStatusDisplay(order) }}
+
+
+
+
+ {{ formatDate(order.time) }}
+
+
+
+
+
+
+
+
+
+
+
+ View Details
+
+
+
+
+ Mark as Paid
+
+
+
+ Mark as Shipped
+
+
+
+
+
+
+
+
+
+
+
+
+ Showing {{ startIndex + 1 }}-{{ endIndex }} of {{ filteredOrders.length }} orders
+
+
+
+ Previous
+
+
+ Page {{ currentPage }} of {{ totalPages }}
+
+
+ Next
+
+
+
+
+
+
+
+
+
+
+
+ Order Details
+
+
+
+
+
+
Order ID
+
{{ selectedOrder.id }}
+
+
+
Status
+
+
+
+ {{ getStatusDisplay(selectedOrder) }}
+
+
+
+
+
Date
+
{{ formatDate(selectedOrder.time) }}
+
+
+
Total
+
{{ formatSats(selectedOrder.total) }}
+
+
+
+
+
+
Customer
+
+
+
+ Nostr:
+ {{ selectedOrder.contact.nostr.slice(0, 16) }}...
+
+
+ Email:
+ {{ selectedOrder.contact.email }}
+
+
+ Phone:
+ {{ selectedOrder.contact.phone }}
+
+
+
+ Public Key:
+ {{ selectedOrder.public_key.slice(0, 16) }}...{{ selectedOrder.public_key.slice(-8) }}
+
+
+
+ No customer information available
+
+
+
+
+
+
+
+
Shipping Address
+
+ {{ selectedOrder.address }}
+
+
+
+
+
+
Items
+
+
+
+
+ Product
+ Price
+ Quantity
+ Subtotal
+
+
+
+
+ {{ item.name }}
+ {{ formatSats(item.price) }}
+ {{ item.quantity }}
+ {{ formatSats(item.price * item.quantity) }}
+
+
+ Shipping
+ {{ formatSats(selectedOrder.extra.shipping_cost_sat) }}
+
+
+ Total
+ {{ formatSats(selectedOrder.total) }}
+
+
+
+
+
+
+
+
+
Customer Message
+
+ {{ selectedOrder.message }}
+
+
+
+
+
+ Close
+
+
+
+
+
+
+
+
\ 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
-
-
- Add Product
-
-
+
+
+ Products
+ Orders
+
+
+
+
+
+
Products
+
+
+ Add Product
+
+
@@ -276,6 +284,19 @@
{{ product.active ? 'Active' : 'Inactive' }}
+
+
+
@@ -292,18 +313,54 @@
-
-
+
+
+
+
+ {{ isDeletingProduct && deletingProductId === product.id ? 'Deleting...' : 'Delete' }}
+
+
+
+
+
+ {{ isResendingProduct && resendingProductId === product.id ? 'Re-sending...' : 'Re-send' }}
+
+
+
+
- Edit
+
+
+ Edit
+
+
+
+
+
+
+
+
@@ -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/components/ProductGrid.vue b/src/modules/market/components/ProductGrid.vue
index 9f26806..7997824 100644
--- a/src/modules/market/components/ProductGrid.vue
+++ b/src/modules/market/components/ProductGrid.vue
@@ -27,23 +27,15 @@
/>
-
-
\ 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 @@
+
+
+
+
+
+
+
+
+
+
Loading product details...
+
+
+
+
+
+
+
{{ error }}
+
+ Try Again
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ product.name }}
+
+
+ {{ formatPrice(product.price, product.currency) }}
+
+
+ Out of Stock
+
+
+ Only {{ product.quantity }} left
+
+
+ In Stock
+
+
+
+
+
+
+
+ Sold by
+ {{ product.stallName }}
+
+
+
+
+
Description
+
{{ product.description }}
+
+
+
+
+
Categories
+
+
+ {{ category }}
+
+
+
+
+
+
+
+
+
+
+
+ Add to Cart
+
+
+ Continue Shopping
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file