Compare commits

...

10 commits

Author SHA1 Message Date
c90def94a7 Squash merge rely-on-nostrmarket-to-publish into main 2025-10-08 09:19:07 +02:00
08b172ab34 Merge branch 'improve-chat' 2025-10-04 10:29:33 +02:00
0da23e9332 Replace complex UnreadMessageData system with elegant path-based wildcard notification tracking inspired by Coracle's pattern. This simplifies the codebase while adding powerful batch "mark as read" capabilities.
Key changes:
  - Add notification store with path-based wildcard support (chat/*, chat/{pubkey}, *)
  - Remove UnreadMessageData interface and processedMessageIds Set tracking
  - Implement timestamp-based "seen at" logic with wildcard matching
  - Add markAllChatsAsRead() for batch operations
  - Integrate ChatNotificationConfig for module configuration
  - Create useNotifications composable for easy notification access

  Benefits:
  - Simpler architecture (removed processedMessageIds complexity)
  - Flexible wildcard-based "mark as read" operations
  - Future-proof for Primal-style backend sync
  - User-scoped storage via StorageService
  - Clean separation of concerns

refactor: enhance chat service with activity tracking and sorting

- Updated the ChatService to track lastSent, lastReceived, and lastChecked timestamps for peers, improving message handling and user experience.
- Implemented sorting of peers by last activity to prioritize recent conversations.
- Adjusted message handling to update peer activity based on message direction.
- Ensured updated peer data is saved to storage after modifications.

These changes streamline chat interactions and enhance the overall functionality of the chat service.

refactor: improve ChatService and notification store initialization

- Updated ChatService to ensure the notification store is initialized only after user authentication, preventing potential errors.
- Introduced a new method to safely access the notification store, enhancing error handling.
- Enhanced peer activity tracking by calculating last activity based on actual message timestamps, improving sorting and user experience.
- Added debounced saving of notification state to storage, optimizing performance and reducing unnecessary writes.
- Improved logging for better debugging and visibility into notification handling processes.

These changes enhance the reliability and efficiency of the chat service and notification management.

refactor: clean up logging in ChatService and notification store

- Removed unnecessary console logs from ChatService to streamline the code and improve performance.
- Simplified the initialization process of the notification store by eliminating redundant logging statements.
- Enhanced readability and maintainability of the code by focusing on essential operations without excessive debug output.

These changes contribute to a cleaner codebase and improved performance in chat service operations.

FIX BUILD ERRORS

refactor: update chat module notification configuration

- Refactored the notification settings in the chat module to use a nested structure, enhancing clarity and organization.
- Introduced `wildcardSupport` to the notification configuration, allowing for more flexible notification handling.
- Maintained existing functionality while improving the overall configuration structure.

These changes contribute to a more maintainable and extensible chat module configuration.

refactor: optimize ChatComponent and ChatService for improved performance

- Removed unnecessary sorting of peers in ChatComponent, leveraging the existing order provided by the chat service.
- Updated the useFuzzySearch composable to directly utilize the sorted peers, enhancing search efficiency.
- Cleaned up logging in ChatService by removing redundant console statements, streamlining the codebase.
- Added critical checks to prevent the current user from being included in peer interactions, improving user experience and functionality.

These changes contribute to a more efficient and maintainable chat module.

refactor: simplify message publishing in ChatService

- Removed unnecessary variable assignment in the message publishing process, directly awaiting the relayHub.publishEvent call.
- This change streamlines the code and enhances readability without altering functionality.

These modifications contribute to a cleaner and more efficient chat service implementation.
2025-10-04 10:29:22 +02:00
0447549fa5 FIX: CRITICAL use nullish coalescing for product quantity in useMarket composable
- Updated the quantity assignment in the useMarket composable to use nullish coalescing (??) instead of logical OR (||). This change ensures that a quantity of 0 is preserved, preventing unintended defaults when productData.quantity is explicitly set to 0.

These changes improve the accuracy of product quantity handling in the market module.
2025-10-02 09:43:16 +02:00
b69be281f3 FIX: remove comment tag; enhance image loading behavior in ProgressiveImage and ImageViewer components
- Added a key to the ProgressiveImage component to ensure proper reactivity when the image source changes.
- Implemented a watcher in the ProgressiveImage component to reset loading state on source changes, improving user experience during image transitions.

These changes enhance the reliability and responsiveness of image handling in the application.
2025-09-28 13:01:50 +02:00
98934ed61d refactor: streamline ImageLightbox and update ProductDetailPage for better image handling
- Removed unused `closeOnBackdropClick` option from `useImageLightbox` for cleaner code.
- Simplified the product assignment in `ProductDetailPage` by creating a mutable copy of product data, ensuring proper handling of images and categories.

These changes enhance the maintainability and clarity of the image handling components in the application.
2025-09-28 12:58:11 +02:00
3742937aea refactor: remove ImageDisplay component and update base module exports
- Deleted the ImageDisplay component to streamline image handling.
- Updated the base module to export only the ImageUpload component, simplifying the component structure.

These changes enhance the clarity and maintainability of the image handling components in the application.
2025-09-28 12:57:57 +02:00
ca0ac2b9ad feat: introduce ImageLightbox and ImageViewer components for enhanced image handling
- Added ImageLightbox component to provide a modal view for images with navigation and keyboard support.
- Implemented ImageViewer component to display images with features like thumbnails, cycling controls, and lightbox integration.
- Updated ProgressiveImage component for improved loading and error handling.
- Refactored image imports in ProductCard, ProductDetailPage, and CheckoutPage to align with new component structure.

These changes significantly enhance the user experience for viewing and interacting with product images across the application.
2025-09-28 12:48:02 +02:00
3aec5bbdb3 feat: add ProductDetailPage introduce ImageViewer and ImageLightbox components for enhanced image display
- ProductDetailPage is being used in lieu of a modal becaues Lightbox
image gallery (modal) being embedded in another modal was causing too
much buggy behavior
- Added ImageViewer component to manage and display product images with
features like lightbox, thumbnails, and image cycling controls.
- Replaced ProgressiveImageGallery with ImageViewer in
ProductDetailDialog and ProductDetailPage for improved user experience
and maintainability.
- Implemented useImageLightbox composable to handle lightbox
functionality, including keyboard navigation and swipe gestures.
- Updated routing to include a dedicated product detail page for better
navigation and user flow.

These changes significantly enhance the image viewing experience in the
product detail context, providing a more dynamic and user-friendly
interface.
2025-09-28 12:39:41 +02:00
bff158cb74 feat: enhance product management with new dialog and image handling features
- Introduced ProductDetailDialog component for displaying detailed product information, including images, price, and availability.
- Implemented image cycling functionality in ProductCard for better user experience when viewing multiple product images.
- Enhanced CreateProductDialog to support image uploads with improved validation and navigation protection during form editing.
- Added logic to manage uploaded images and ensure proper handling of existing product images.
- Updated MarketPage to integrate the new ProductDetailDialog, allowing users to view product details seamlessly.

These changes significantly improve the product management experience, enhancing both the display and interaction with product images.
2025-09-28 12:39:41 +02:00
45 changed files with 3600 additions and 948 deletions

View file

@ -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)

16
package-lock.json generated
View file

@ -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"

View file

@ -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",

View file

@ -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: {

View file

@ -0,0 +1,241 @@
<template>
<!-- Simple lightbox overlay - always teleported to body -->
<Teleport to="body">
<div
v-if="lightbox.isOpen.value"
class="image-lightbox-overlay fixed inset-0 bg-background/90 backdrop-blur-sm z-[9999] flex items-center justify-center"
@click="lightbox.close"
>
<!-- Lightbox container -->
<div
class="image-lightbox-container relative max-w-[95vw] max-h-[95vh] bg-transparent rounded-lg overflow-hidden"
@click.stop
>
<!-- Main image display -->
<div class="image-lightbox-content relative">
<ProgressiveImage
v-if="lightbox.currentImage.value"
:src="lightbox.currentImage.value.src"
:alt="lightbox.currentImage.value.alt || 'Lightbox image'"
container-class="flex items-center justify-center"
image-class="max-w-full max-h-[95vh] object-contain"
:blur-radius="8"
:transition-duration="400"
:show-loading-indicator="true"
/>
</div>
<!-- Close button -->
<Button
@click.stop="lightbox.close"
variant="ghost"
size="icon"
class="absolute top-4 right-4 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
aria-label="Close lightbox"
>
<X class="h-5 w-5" />
</Button>
<!-- Navigation buttons -->
<template v-if="lightbox.hasPrevious.value">
<Button
@click.stop="lightbox.goToPrevious"
variant="ghost"
size="icon"
class="absolute left-4 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
aria-label="Previous image"
>
<ChevronLeft class="h-6 w-6" />
</Button>
</template>
<template v-if="lightbox.hasNext.value">
<Button
@click.stop="lightbox.goToNext"
variant="ghost"
size="icon"
class="absolute right-4 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
aria-label="Next image"
>
<ChevronRight class="h-6 w-6" />
</Button>
</template>
<!-- Image counter -->
<div
v-if="lightbox.totalImages.value > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded-lg px-3 py-1.5 text-sm font-medium border border-border/50 shadow-lg"
>
{{ lightbox.currentIndex.value + 1 }} / {{ lightbox.totalImages.value }}
</div>
<!-- Keyboard navigation hint (visible for a few seconds) -->
<Transition
enter-active-class="transition-opacity duration-300"
leave-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="showKeyboardHint"
class="absolute top-4 left-1/2 -translate-x-1/2 bg-background/90 backdrop-blur rounded-lg px-4 py-2 text-sm text-muted-foreground border border-border/50 shadow-lg"
>
Use arrow keys or swipe to navigate ESC to close
</div>
</Transition>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import ProgressiveImage from './ProgressiveImage.vue'
import { useImageLightbox, type LightboxImage, type UseImageLightboxOptions } from './composables/useImageLightbox'
interface Props {
/**
* Array of images to display in the lightbox
*/
images: LightboxImage[]
/**
* Lightbox configuration options
*/
options?: UseImageLightboxOptions
/**
* Whether to show the keyboard navigation hint
*/
showKeyboardHint?: boolean
/**
* Duration to show keyboard hint in milliseconds
*/
keyboardHintDuration?: number
}
const props = withDefaults(defineProps<Props>(), {
options: () => ({}),
showKeyboardHint: true,
keyboardHintDuration: 3000
})
const emit = defineEmits<{
open: [index: number]
close: []
navigate: [index: number]
}>()
// Initialize lightbox composable
const lightbox = useImageLightbox(props.images, props.options)
// Keyboard hint visibility
const showKeyboardHint = ref(false)
let keyboardHintTimeout: NodeJS.Timeout | null = null
// Watch for lightbox open/close events
watch(lightbox.isOpen, (isOpen) => {
if (isOpen) {
emit('open', lightbox.currentIndex.value)
// Show keyboard hint
if (props.showKeyboardHint) {
showKeyboardHint.value = true
if (keyboardHintTimeout) {
clearTimeout(keyboardHintTimeout)
}
keyboardHintTimeout = setTimeout(() => {
showKeyboardHint.value = false
}, props.keyboardHintDuration)
}
} else {
emit('close')
showKeyboardHint.value = false
if (keyboardHintTimeout) {
clearTimeout(keyboardHintTimeout)
}
}
})
// Watch for navigation events
watch(lightbox.currentIndex, (newIndex) => {
emit('navigate', newIndex)
})
// Cleanup timeout on unmount
onMounted(() => {
return () => {
if (keyboardHintTimeout) {
clearTimeout(keyboardHintTimeout)
}
}
})
// Expose lightbox methods for parent components
defineExpose({
open: lightbox.open,
close: lightbox.close,
goToPrevious: lightbox.goToPrevious,
goToNext: lightbox.goToNext,
goToIndex: lightbox.goToIndex,
isOpen: lightbox.isOpen,
currentIndex: lightbox.currentIndex,
currentImage: lightbox.currentImage
})
</script>
<style scoped>
/* Ensure lightbox appears above all other content */
.image-lightbox-overlay {
/* Using high z-index to ensure proper stacking */
z-index: 9999;
/* Smooth backdrop animation */
animation: fadeIn 0.2s ease-out;
}
.image-lightbox-container {
/* Smooth container animation */
animation: scaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Prevent content from jumping when overlay appears */
.image-lightbox-overlay * {
box-sizing: border-box;
}
/* Animation keyframes */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Accessibility: respect reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.image-lightbox-overlay,
.image-lightbox-container {
animation: none !important;
}
}
</style>

View file

@ -0,0 +1,334 @@
<template>
<div class="image-viewer">
<!-- Primary image display with progressive loading -->
<div v-if="currentImageSrc" class="primary-image relative">
<ProgressiveImage
:key="`image-${currentImageIndex}-${currentImageSrc}`"
:src="currentImageSrc"
:alt="alt || 'Image'"
:container-class="containerClass"
:image-class="[imageClass, showLightbox ? 'cursor-pointer' : ''].join(' ')"
:blur-radius="blurRadius"
:transition-duration="transitionDuration"
:loading="loading"
:show-loading-indicator="showLoadingIndicator"
@click="handleImageClick"
@error="handleImageError"
/>
<!-- Image counter badge -->
<Badge
v-if="showBadge && images.length > 1"
class="absolute top-2 right-2"
variant="secondary"
>
{{ currentImageIndex + 1 }} of {{ images.length }}
</Badge>
<!-- Image cycling controls (if multiple images) -->
<template v-if="images.length > 1 && showCycleControls">
<Button
@click="previousImage"
variant="ghost"
size="icon"
class="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Previous image"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
@click="nextImage"
variant="ghost"
size="icon"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 opacity-0 group-hover:opacity-100 transition-opacity"
aria-label="Next image"
>
<ChevronRight class="h-4 w-4" />
</Button>
</template>
</div>
<!-- Fallback when no image -->
<div v-else :class="containerClass">
<div class="w-full h-full bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
<div class="text-center">
<Package class="w-12 h-12 mx-auto text-muted-foreground mb-2" />
<span class="text-xs text-muted-foreground">No image available</span>
</div>
</div>
</div>
<!-- Thumbnail gallery -->
<div
v-if="showThumbnails && images.length > 1"
class="thumbnail-gallery flex gap-2 mt-3 overflow-x-auto"
>
<button
v-for="(imageSrc, index) in images"
:key="index"
@click="selectImage(index)"
class="thumbnail-item flex-shrink-0 rounded-md overflow-hidden border-2 transition-all"
:class="{
'border-primary': index === currentImageIndex,
'border-transparent hover:border-muted-foreground': index !== currentImageIndex
}"
:aria-label="`View image ${index + 1}`"
>
<ProgressiveImage
:src="imageSrc"
:alt="`Thumbnail ${index + 1}`"
container-class="w-16 h-16"
image-class="w-16 h-16 object-cover"
:blur-radius="4"
:transition-duration="200"
loading="lazy"
:show-loading-indicator="false"
/>
</button>
</div>
<!-- Lightbox -->
<ImageLightbox
v-if="showLightbox"
ref="lightboxRef"
:images="lightboxImages"
:options="lightboxOptions"
@open="handleLightboxOpen"
@close="handleLightboxClose"
@navigate="handleLightboxNavigate"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ChevronLeft, ChevronRight, Package } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ProgressiveImage from './ProgressiveImage.vue'
import ImageLightbox from './ImageLightbox.vue'
import type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox'
interface Props {
/**
* Array of image URLs to display
*/
images: string[]
/**
* Alt text for images
*/
alt?: string
/**
* CSS classes for the container
*/
containerClass?: string
/**
* CSS classes for the image element
*/
imageClass?: string
/**
* Blur radius for progressive loading placeholder
*/
blurRadius?: number
/**
* Transition duration for progressive loading
*/
transitionDuration?: number
/**
* Image loading strategy
*/
loading?: 'lazy' | 'eager'
/**
* Whether to show loading indicator
*/
showLoadingIndicator?: boolean
/**
* Whether to show thumbnail gallery
*/
showThumbnails?: boolean
/**
* Whether to enable lightbox functionality
*/
showLightbox?: boolean
/**
* Whether to show image counter badge
*/
showBadge?: boolean
/**
* Whether to show image cycling controls on hover
*/
showCycleControls?: boolean
/**
* Initial image index to display
*/
initialIndex?: number
/**
* Lightbox configuration options
*/
lightboxOptions?: UseImageLightboxOptions
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
containerClass: 'w-full h-48 bg-muted/50 group',
imageClass: 'w-full h-48 object-cover',
blurRadius: 8,
transitionDuration: 400,
loading: 'lazy',
showLoadingIndicator: true,
showThumbnails: true,
showLightbox: true,
showBadge: true,
showCycleControls: true,
initialIndex: 0,
lightboxOptions: () => ({})
})
const emit = defineEmits<{
error: [error: Event]
imageChange: [index: number, src: string]
lightboxOpen: [index: number]
lightboxClose: []
}>()
// Component state
const currentImageIndex = ref(props.initialIndex)
const lightboxRef = ref<InstanceType<typeof ImageLightbox>>()
// Computed properties
const filteredImages = computed(() => {
return props.images.filter(img => img && img.trim() !== '')
})
const currentImageSrc = computed(() => {
if (filteredImages.value.length === 0) return null
const index = Math.min(currentImageIndex.value, filteredImages.value.length - 1)
return filteredImages.value[index]
})
const lightboxImages = computed((): LightboxImage[] => {
return filteredImages.value.map((src, index) => ({
src,
alt: `${props.alt || 'Image'} ${index + 1}`
}))
})
// Methods
const selectImage = (index: number) => {
if (index >= 0 && index < filteredImages.value.length) {
currentImageIndex.value = index
emit('imageChange', index, filteredImages.value[index])
}
}
const previousImage = () => {
const newIndex = currentImageIndex.value > 0
? currentImageIndex.value - 1
: filteredImages.value.length - 1
selectImage(newIndex)
}
const nextImage = () => {
const newIndex = currentImageIndex.value < filteredImages.value.length - 1
? currentImageIndex.value + 1
: 0
selectImage(newIndex)
}
const handleImageClick = () => {
if (props.showLightbox && lightboxRef.value) {
lightboxRef.value.open(currentImageIndex.value)
}
}
const handleImageError = (error: Event) => {
emit('error', error)
}
const handleLightboxOpen = (index: number) => {
emit('lightboxOpen', index)
}
const handleLightboxClose = () => {
emit('lightboxClose')
}
const handleLightboxNavigate = (index: number) => {
// Sync the main viewer with lightbox navigation
currentImageIndex.value = index
emit('imageChange', index, filteredImages.value[index])
}
// Watch for changes in images array
watch(() => props.images, () => {
// Reset to first image if current index is out of bounds
if (currentImageIndex.value >= filteredImages.value.length) {
currentImageIndex.value = 0
}
}, { immediate: true })
// Watch for initialIndex changes
watch(() => props.initialIndex, (newIndex) => {
if (newIndex !== currentImageIndex.value) {
selectImage(newIndex)
}
}, { immediate: true })
// Expose methods for parent components
defineExpose({
selectImage,
openLightbox: () => lightboxRef.value?.open(currentImageIndex.value),
closeLightbox: () => lightboxRef.value?.close(),
getCurrentIndex: () => currentImageIndex.value,
getCurrentImage: () => currentImageSrc.value,
previousImage,
nextImage
})
</script>
<style scoped>
.image-viewer {
@apply w-full;
}
.thumbnail-gallery {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground)) transparent;
}
.thumbnail-gallery::-webkit-scrollbar {
height: 6px;
}
.thumbnail-gallery::-webkit-scrollbar-track {
background: transparent;
}
.thumbnail-gallery::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground));
border-radius: 3px;
}
.thumbnail-gallery::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--foreground));
}
/* Smooth transitions for all interactive elements */
.thumbnail-item,
.primary-image button {
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View file

@ -17,7 +17,7 @@
'progressive-image-loaded': isLoaded,
'progressive-image-error': hasError
}
]" :loading="loading" @load="handleLoad" @error="handleError" /> -->
]" :loading="loading" @load="handleLoad" @error="handleError" />
<!-- Loading indicator (optional) -->
<div v-if="showLoadingIndicator && !isLoaded && !hasError" class="progressive-image-loading-indicator">
@ -37,7 +37,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { Package } from 'lucide-vue-next'
interface Props {
@ -189,6 +189,17 @@ onMounted(() => {
}
})
// Watch for src changes and reset loading state
watch(() => props.src, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) {
// Reset loading state when image source changes
isLoaded.value = false
hasError.value = false
isLoading.value = true
emit('loading-start')
}
})
// Expose methods for parent components
defineExpose({
reload: () => {

View file

@ -0,0 +1,187 @@
import { ref, computed, watch, onBeforeUnmount } from 'vue'
export interface LightboxImage {
src: string
alt?: string
}
export interface UseImageLightboxOptions {
/**
* Whether to enable keyboard navigation (arrow keys, escape)
*/
enableKeyboardNavigation?: boolean
/**
* Whether to close lightbox when clicking backdrop
*/
closeOnBackdropClick?: boolean
/**
* Whether to enable swipe gestures on touch devices
*/
enableSwipeGestures?: boolean
}
export function useImageLightbox(
images: LightboxImage[],
options: UseImageLightboxOptions = {}
) {
const {
enableKeyboardNavigation = true,
enableSwipeGestures = true
} = options
// Core reactive state
const isOpen = ref(false)
const currentIndex = ref(0)
// Computed properties
const currentImage = computed(() => {
if (!images.length || currentIndex.value < 0) return null
return images[Math.min(currentIndex.value, images.length - 1)]
})
const hasPrevious = computed(() => images.length > 1)
const hasNext = computed(() => images.length > 1)
const totalImages = computed(() => images.length)
// Navigation methods
const open = (index: number = 0) => {
if (images.length === 0) return
currentIndex.value = Math.max(0, Math.min(index, images.length - 1))
isOpen.value = true
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden'
}
const close = () => {
isOpen.value = false
// Restore body scroll
document.body.style.overflow = ''
}
const goToPrevious = () => {
if (!hasPrevious.value) return
currentIndex.value = currentIndex.value > 0
? currentIndex.value - 1
: images.length - 1
}
const goToNext = () => {
if (!hasNext.value) return
currentIndex.value = currentIndex.value < images.length - 1
? currentIndex.value + 1
: 0
}
const goToIndex = (index: number) => {
if (index < 0 || index >= images.length) return
currentIndex.value = index
}
// Keyboard navigation
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value || !enableKeyboardNavigation) return
switch (event.key) {
case 'Escape':
event.preventDefault()
close()
break
case 'ArrowLeft':
event.preventDefault()
goToPrevious()
break
case 'ArrowRight':
event.preventDefault()
goToNext()
break
case ' ': // Spacebar
event.preventDefault()
goToNext()
break
}
}
// Touch/swipe gesture handling
let touchStartX = 0
let touchStartY = 0
const swipeThreshold = 50
const handleTouchStart = (event: TouchEvent) => {
if (!enableSwipeGestures || !isOpen.value) return
touchStartX = event.touches[0].clientX
touchStartY = event.touches[0].clientY
}
const handleTouchEnd = (event: TouchEvent) => {
if (!enableSwipeGestures || !isOpen.value) return
const touchEndX = event.changedTouches[0].clientX
const touchEndY = event.changedTouches[0].clientY
const deltaX = touchEndX - touchStartX
const deltaY = touchEndY - touchStartY
// Only process horizontal swipes (ignore mostly vertical swipes)
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > swipeThreshold) {
if (deltaX > 0) {
goToPrevious()
} else {
goToNext()
}
}
}
// Setup event listeners
watch(isOpen, (newIsOpen) => {
if (newIsOpen) {
document.addEventListener('keydown', handleKeydown)
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
} else {
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
}
})
// Cleanup on unmount
onBeforeUnmount(() => {
close()
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleTouchEnd)
})
return {
// State
isOpen: readonly(isOpen),
currentIndex: readonly(currentIndex),
currentImage,
// Computed
hasPrevious,
hasNext,
totalImages,
// Methods
open,
close,
goToPrevious,
goToNext,
goToIndex
}
}
// Helper to create readonly refs
function readonly<T>(ref: import('vue').Ref<T>) {
return computed(() => ref.value)
}

View file

@ -0,0 +1,10 @@
// Image Components
export { default as ImageLightbox } from './ImageLightbox.vue'
export { default as ImageViewer } from './ImageViewer.vue'
export { default as ProgressiveImage } from './ProgressiveImage.vue'
// Composables
export { useImageLightbox } from './composables/useImageLightbox'
// Types
export type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox'

View file

@ -0,0 +1,16 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View file

@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>

View file

@ -0,0 +1,34 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { cn } from "@/lib/utils"
import TableCell from "./TableCell.vue"
import TableRow from "./TableRow.vue"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
colspan?: number
}>(), {
colspan: 1,
})
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tfoot
data-slot="table-footer"
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
>
<slot />
</tfoot>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<th
data-slot="table-head"
:class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
>
<slot />
</th>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<thead
data-slot="table-header"
:class="cn('[&_tr]:border-b', props.class)"
>
<slot />
</thead>
</template>

View file

@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<tr
data-slot="table-row"
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
>
<slot />
</tr>
</template>

View file

@ -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"

View file

@ -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<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue
}

View file

@ -1,238 +0,0 @@
<template>
<div class="image-display">
<!-- Primary image display -->
<div v-if="primaryImage" class="primary-image relative">
<img
:src="getImageUrl(primaryImage, props.sizing?.primaryImageOptions)"
:alt="alt || 'Image'"
:class="imageClass"
@click="showLightbox && openLightbox(primaryImage)"
:style="{ cursor: showLightbox ? 'pointer' : 'default' }"
/>
<Badge
v-if="showBadge && images.length > 1"
class="absolute top-2 right-2"
variant="secondary"
>
1 of {{ images.length }}
</Badge>
</div>
<!-- Thumbnail gallery -->
<div
v-if="showThumbnails && images.length > 1"
class="thumbnail-list flex gap-2 mt-3 overflow-x-auto"
>
<div
v-for="(image, index) in images"
:key="image.alias"
@click="selectImage(image)"
class="thumbnail-item flex-shrink-0 cursor-pointer rounded-md overflow-hidden border-2 transition-colors"
:class="{
'border-primary': image.alias === primaryImage?.alias,
'border-transparent hover:border-muted-foreground': image.alias !== primaryImage?.alias
}"
>
<img
:src="imageService.getThumbnailUrl(image.alias, thumbnailSize)"
:alt="`Thumbnail ${index + 1}`"
class="w-16 h-16 object-cover"
/>
</div>
</div>
<!-- Lightbox modal -->
<Dialog v-if="!isEmbedded" v-model:open="lightboxOpen">
<DialogContent class="max-w-4xl p-0">
<div class="relative">
<img
v-if="lightboxImage"
:src="getImageUrl(lightboxImage)"
:alt="alt || 'Full size image'"
class="w-full h-auto"
/>
<Button
@click="lightboxOpen = false"
variant="ghost"
size="icon"
class="absolute top-2 right-2 bg-background/80 backdrop-blur hover:bg-background/90"
>
<X class="h-4 w-4" />
</Button>
<!-- Navigation buttons if multiple images -->
<template v-if="images.length > 1">
<Button
@click="previousImage"
variant="ghost"
size="icon"
class="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
@click="nextImage"
variant="ghost"
size="icon"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
>
<ChevronRight class="h-4 w-4" />
</Button>
</template>
</div>
</DialogContent>
</Dialog>
<!-- Embedded lightbox (when used inside another dialog) -->
<div
v-if="isEmbedded && lightboxOpen"
class="fixed inset-0 z-[60] bg-background/80 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="lightboxOpen = false"
>
<div class="relative max-w-[90vw] max-h-[90vh] bg-background rounded-lg p-0 shadow-lg">
<div class="relative">
<img
v-if="lightboxImage"
:src="getImageUrl(lightboxImage)"
:alt="alt || 'Full size image'"
class="max-w-full max-h-[90vh] object-contain rounded-lg"
/>
<Button
@click="lightboxOpen = false"
variant="ghost"
size="icon"
class="absolute top-2 right-2 bg-background/80 backdrop-blur hover:bg-background/90"
>
<X class="h-4 w-4" />
</Button>
<!-- Navigation buttons if multiple images -->
<template v-if="images.length > 1">
<Button
@click="previousImage"
variant="ghost"
size="icon"
class="absolute left-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
>
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
@click="nextImage"
variant="ghost"
size="icon"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90"
>
<ChevronRight class="h-4 w-4" />
</Button>
</template>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, defineProps } from 'vue'
import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
} from '@/components/ui/dialog'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { ImageUploadService, ImageUrlOptions } from '../services/ImageUploadService'
export interface DisplayImage {
alias: string
isPrimary?: boolean
}
interface ImageDisplayOptions {
showThumbnails?: boolean
showLightbox?: boolean
showBadge?: boolean
isEmbedded?: boolean
}
interface ImageSizingOptions {
thumbnailSize?: number
primaryImageOptions?: ImageUrlOptions
}
const props = defineProps<{
images: DisplayImage[]
alt?: string
imageClass?: string
options?: ImageDisplayOptions
sizing?: ImageSizingOptions
}>()
// Inject the image upload service
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
// Component state
const selectedImageAlias = ref<string | null>(null)
const lightboxOpen = ref(false)
const lightboxImage = ref<DisplayImage | null>(null)
// Computed properties
const primaryImage = computed(() => {
if (selectedImageAlias.value) {
return props.images.find(img => img.alias === selectedImageAlias.value)
}
return props.images.find(img => img.isPrimary) || props.images[0]
})
const thumbnailSize = computed(() => props.sizing?.thumbnailSize || 100)
const isEmbedded = computed(() => props.options?.isEmbedded || false)
const showThumbnails = computed(() => props.options?.showThumbnails || false)
const showLightbox = computed(() => props.options?.showLightbox || false)
const showBadge = computed(() => props.options?.showBadge || false)
// Methods
const getImageUrl = (image: DisplayImage, options?: ImageUrlOptions) => {
// Check if the alias is already a full URL
if (image.alias.startsWith('http://') || image.alias.startsWith('https://')) {
// Already a full URL, return as-is
return image.alias
}
// Otherwise, use the image service to generate the URL
const defaultOptions = options || props.sizing?.primaryImageOptions || { resize: 800 }
return imageService.getImageUrl(image.alias, defaultOptions)
}
const selectImage = (image: DisplayImage) => {
selectedImageAlias.value = image.alias
}
const openLightbox = (image: DisplayImage) => {
lightboxImage.value = image
lightboxOpen.value = true
}
const getCurrentImageIndex = () => {
if (!lightboxImage.value) return 0
return props.images.findIndex(img => img.alias === lightboxImage.value?.alias)
}
const previousImage = () => {
const currentIndex = getCurrentImageIndex()
const newIndex = currentIndex > 0 ? currentIndex - 1 : props.images.length - 1
lightboxImage.value = props.images[newIndex]
}
const nextImage = () => {
const currentIndex = getCurrentImageIndex()
const newIndex = currentIndex < props.images.length - 1 ? currentIndex + 1 : 0
lightboxImage.value = props.images[newIndex]
}
// Expose methods for parent components
defineExpose({
selectImage,
openLightbox,
getCurrentImage: () => primaryImage.value
})
</script>

View file

@ -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
}
}

View file

@ -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

View file

@ -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
}
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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<typeof setInterval>
private notificationStore?: ReturnType<typeof useChatNotificationStore>
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<void>) {
this.marketMessageHandler = handler
}
/**
* Get the notification store, ensuring it's initialized
* CRITICAL: This must only be called after onInitialize() has run
*/
private getNotificationStore(): ReturnType<typeof useChatNotificationStore> {
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<void> {
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<void> {
// 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<void> {
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<string>()
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)

View file

@ -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<StorageService>(SERVICE_TOKENS.STORAGE_SERVICE)
// State: path -> timestamp mappings
const checked = ref<Record<string, number>>({})
/**
* Load notification state from storage
*/
const loadFromStorage = () => {
if (!storageService) {
console.warn('📢 Cannot load chat notifications: StorageService not available')
return
}
const stored = storageService.getUserData<Record<string, number>>(STORAGE_KEY, {})
checked.value = stored || {}
}
// Debounce timer for storage writes
let saveDebounce: ReturnType<typeof setTimeout> | 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
}
})

View file

@ -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<string>
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

View file

@ -127,11 +127,17 @@
<FormField name="images">
<FormItem>
<FormLabel>Product Images</FormLabel>
<FormDescription>Add images to showcase your product</FormDescription>
<div class="text-center py-8 border-2 border-dashed rounded-lg">
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">Image upload coming soon</p>
</div>
<FormDescription>Add up to 5 images to showcase your product. The first image will be the primary display image.</FormDescription>
<ImageUpload
v-model="uploadedImages"
:multiple="true"
:max-files="5"
:max-size-mb="10"
:show-primary-button="true"
:disabled="isCreating"
:allow-camera="true"
placeholder="Add product photos"
/>
<FormMessage />
</FormItem>
</FormField>
@ -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<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
const toast = useToast()
// Local state
const isCreating = ref(false)
const createError = ref<string | null>(null)
const uploadedImages = ref<any[]>([]) // 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()

View file

@ -215,69 +215,107 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useMarketStore } from '../stores/market'
import { useAuth } from '@/composables/useAuthService'
import { useMarket } from '../composables/useMarket'
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Package,
Store,
ShoppingCart,
DollarSign,
import {
Package,
Store,
ShoppingCart,
DollarSign,
BarChart3,
Clock
} from 'lucide-vue-next'
import type { OrderApiResponse, NostrmarketAPI } from '../services/nostrmarketAPI'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { PaymentService } from '@/core/services/PaymentService'
const router = useRouter()
const marketStore = useMarketStore()
const auth = useAuth()
const { isConnected } = useMarket()
// const orderEvents = useOrderEvents() // TODO: Move to market module
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
// State
const orders = ref<OrderApiResponse[]>([])
const isLoadingOrders = ref(false)
const orderEvents = { isSubscribed: ref(false) } // Temporary mock
// Methods to fetch orders
const fetchOrders = async () => {
isLoadingOrders.value = true
try {
const inkey = paymentService.getPreferredWalletInvoiceKey()
if (inkey) {
const apiOrders = await nostrmarketAPI.getOrders(inkey)
orders.value = apiOrders
}
} catch (error) {
console.error('Failed to fetch orders:', error)
} finally {
isLoadingOrders.value = false
}
}
// Computed properties
const orderStats = computed(() => {
const orders = Object.values(marketStore.orders)
const allOrders = orders.value
const now = Date.now() / 1000
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
const unpaidOrders = allOrders.filter(o => !o.paid)
const paidOrders = allOrders.filter(o => o.paid)
const shippedOrders = allOrders.filter(o => o.shipped)
// Calculate pending amount (unpaid orders)
const pendingAmount = unpaidOrders.reduce((sum, o) => sum + (o.total || 0), 0)
// Calculate recent sales (paid orders in last 7 days)
const recentSales = paidOrders.filter(o => {
const orderTime = o.time || 0
return orderTime > sevenDaysAgo
})
return {
total: orders.length,
pending: orders.filter(o => o.status === 'pending').length,
paid: orders.filter(o => o.status === 'paid').length,
pendingPayments: orders.filter(o => o.paymentStatus === 'pending').length,
pendingAmount: orders
.filter(o => o.paymentStatus === 'pending')
.reduce((sum, o) => sum + o.total, 0),
recentSales: orders.filter(o =>
o.status === 'paid' && o.createdAt > sevenDaysAgo
).length,
active: orders.filter(o =>
['pending', 'paid', 'processing'].includes(o.status)
).length,
connected: false
total: allOrders.length,
pending: unpaidOrders.length,
paid: paidOrders.length,
shipped: shippedOrders.length,
pendingPayments: unpaidOrders.length,
pendingAmount: pendingAmount,
recentSales: recentSales.length,
active: allOrders.filter(o => o.paid && !o.shipped).length,
connected: isConnected.value
}
})
const recentActivity = computed(() => {
const orders = Object.values(marketStore.orders)
const allOrders = orders.value
const now = Date.now() / 1000
const recentOrders = orders
.filter(o => o.updatedAt > now - (24 * 60 * 60)) // Last 24 hours
.sort((a, b) => b.updatedAt - a.updatedAt)
const oneDayAgo = now - (24 * 60 * 60)
// Sort by time and get recent orders
const recentOrders = allOrders
.filter(o => (o.time || 0) > oneDayAgo) // Last 24 hours
.sort((a, b) => (b.time || 0) - (a.time || 0))
.slice(0, 5)
return recentOrders.map(order => ({
id: order.id,
type: 'order',
title: `Order ${order.id.slice(-8)} - ${order.status}`,
status: order.status,
timestamp: order.updatedAt
}))
return recentOrders.map(order => {
let status = 'pending'
if (order.shipped) status = 'shipped'
else if (order.paid) status = 'paid'
return {
id: order.id,
type: 'order',
title: `Order ${order.id.slice(-8)}`,
status: status,
timestamp: order.time || 0
}
})
})
// Methods
@ -308,5 +346,10 @@ const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
const navigateToCart = () => router.push('/cart')
const navigateToStore = () => router.push('/market-dashboard?tab=store')
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
// Load orders when component mounts
onMounted(() => {
fetchOrders()
})
</script>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Trash2, AlertTriangle } from 'lucide-vue-next'
interface Props {
isOpen: boolean
productName: string
isDeleting?: boolean
}
interface Emits {
(e: 'confirm'): void
(e: 'cancel'): void
(e: 'update:isOpen', value: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
isDeleting: false
})
const emit = defineEmits<Emits>()
// Use external control for dialog state
const isOpen = computed({
get: () => props.isOpen,
set: (value: boolean) => {
emit('update:isOpen', value)
}
})
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
isOpen.value = false
emit('cancel')
}
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="sm:max-w-md">
<DialogHeader class="space-y-4">
<div class="mx-auto w-12 h-12 rounded-full bg-gradient-to-br from-destructive to-destructive/80 p-0.5">
<div class="w-full h-full rounded-full bg-background flex items-center justify-center">
<AlertTriangle class="h-6 w-6 text-destructive" />
</div>
</div>
<div class="text-center space-y-2">
<DialogTitle class="text-xl font-semibold text-foreground">
Delete Product
</DialogTitle>
<DialogDescription class="text-muted-foreground">
Are you sure you want to delete <strong>"{{ productName }}"</strong>? This action cannot be undone and will remove the product from your store.
</DialogDescription>
</div>
</DialogHeader>
<DialogFooter class="flex flex-col sm:flex-row gap-2 sm:gap-3">
<Button
variant="ghost"
@click="handleCancel"
class="flex-1 sm:flex-none"
:disabled="isDeleting"
>
Cancel
</Button>
<Button
variant="destructive"
@click="handleConfirm"
class="flex-1 sm:flex-none"
:disabled="isDeleting"
>
<div class="flex items-center">
<Trash2 class="h-4 w-4 mr-2" />
{{ isDeleting ? 'Deleting...' : 'Delete Product' }}
</div>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -0,0 +1,744 @@
<template>
<div class="space-y-6">
<!-- Statistics Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Orders</CardTitle>
<div class="w-8 h-8 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center">
<Package class="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ orderStats.totalOrders }}</div>
<p class="text-xs text-muted-foreground">
<span class="text-green-600 dark:text-green-400 font-medium">{{ orderStats.paidOrders }} paid</span>,
<span class="text-orange-600 dark:text-orange-400 font-medium">{{ orderStats.unpaidOrders }} pending</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Total Revenue</CardTitle>
<div class="w-8 h-8 bg-yellow-100 dark:bg-yellow-900/20 rounded-full flex items-center justify-center">
<Zap class="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-green-700 dark:text-green-300">{{ formatSats(orderStats.totalRevenue) }}</div>
<p class="text-xs text-muted-foreground">
<span class="text-orange-600 dark:text-orange-400 font-medium">{{ formatSats(orderStats.pendingRevenue) }} pending</span>
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">Pending Payment</CardTitle>
<div class="w-8 h-8 bg-orange-100 dark:bg-orange-900/20 rounded-full flex items-center justify-center">
<Clock class="h-4 w-4 text-orange-600 dark:text-orange-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-orange-700 dark:text-orange-300">{{ orderStats.unpaidOrders }}</div>
<p class="text-xs text-muted-foreground">
{{ formatSats(orderStats.pendingRevenue) }} sats total
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle class="text-sm font-medium">To Ship</CardTitle>
<div class="w-8 h-8 bg-purple-100 dark:bg-purple-900/20 rounded-full flex items-center justify-center">
<Truck class="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
</CardHeader>
<CardContent>
<div class="text-2xl font-bold text-purple-700 dark:text-purple-300">{{ orderStats.toShipOrders }}</div>
<p class="text-xs text-muted-foreground">
Paid but not shipped
</p>
</CardContent>
</Card>
</div>
<!-- Orders Table -->
<Card>
<CardHeader>
<div class="flex items-center justify-between">
<CardTitle>Orders</CardTitle>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
@click="refreshOrders"
:disabled="isLoading"
class="border-blue-200 text-blue-700 hover:bg-blue-50 dark:border-blue-800 dark:text-blue-300 dark:hover:bg-blue-900/20"
>
<RefreshCw :class="{ 'animate-spin text-blue-600': isLoading }" class="h-4 w-4 mr-2 text-blue-600" />
Refresh
</Button>
<Select v-model="filterStatus">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Orders</SelectItem>
<SelectItem value="unpaid">Unpaid</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="shipped">Shipped</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<div v-if="isLoading && orders.length === 0" class="text-center py-8">
<div class="w-16 h-16 bg-blue-100 dark:bg-blue-900/20 rounded-full flex items-center justify-center mx-auto mb-4">
<Loader2 class="h-8 w-8 animate-spin text-blue-600 dark:text-blue-400" />
</div>
<p class="text-muted-foreground">Loading orders...</p>
</div>
<div v-else-if="filteredOrders.length === 0" class="text-center py-8">
<div class="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<Package class="h-8 w-8 text-gray-400 dark:text-gray-500" />
</div>
<p class="text-muted-foreground">No orders found</p>
<p class="text-sm text-muted-foreground mt-1">Orders will appear here when customers place them</p>
</div>
<div v-else class="space-y-4">
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Items</TableHead>
<TableHead>Total</TableHead>
<TableHead>Status</TableHead>
<TableHead>Date</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="order in paginatedOrders" :key="order.id">
<TableCell class="font-mono text-sm">
{{ order.id.slice(-8) }}
</TableCell>
<TableCell>
<div class="text-sm">
{{ getCustomerDisplay(order) }}
</div>
</TableCell>
<TableCell>
<div class="text-sm">
{{ getItemsDisplay(order) }}
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-1">
<Zap class="h-3 w-3 text-yellow-500" />
<span class="font-medium text-green-700 dark:text-green-300">{{ formatSats(order.total) }}</span>
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full"
:class="getStatusDotColor(order)"
></div>
<Badge :class="getStatusBadgeClass(order)">
{{ getStatusDisplay(order) }}
</Badge>
</div>
</TableCell>
<TableCell>
{{ formatDate(order.time) }}
</TableCell>
<TableCell class="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="viewOrderDetails(order)">
<Eye class="h-4 w-4 mr-2 text-blue-600" />
View Details
</DropdownMenuItem>
<DropdownMenuSeparator v-if="!order.paid || !order.shipped" />
<DropdownMenuItem
v-if="!order.paid"
@click="markAsPaid(order)"
>
<CheckCircle class="h-4 w-4 mr-2 text-green-600" />
Mark as Paid
</DropdownMenuItem>
<DropdownMenuItem
v-if="order.paid && !order.shipped"
@click="markAsShipped(order)"
>
<Truck class="h-4 w-4 mr-2 text-purple-600" />
Mark as Shipped
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
Showing {{ startIndex + 1 }}-{{ endIndex }} of {{ filteredOrders.length }} orders
</div>
<div class="flex items-center gap-2">
<Button
variant="outline"
size="sm"
@click="currentPage--"
:disabled="currentPage === 1"
>
Previous
</Button>
<div class="text-sm">
Page {{ currentPage }} of {{ totalPages }}
</div>
<Button
variant="outline"
size="sm"
@click="currentPage++"
:disabled="currentPage === totalPages"
>
Next
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<!-- Order Details Dialog -->
<Dialog v-model:open="showOrderDetails">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>Order Details</DialogTitle>
</DialogHeader>
<div v-if="selectedOrder" class="space-y-4">
<!-- Order Info -->
<div class="grid grid-cols-2 gap-4">
<div>
<Label>Order ID</Label>
<div class="font-mono text-sm">{{ selectedOrder.id }}</div>
</div>
<div>
<Label>Status</Label>
<div class="flex items-center gap-2 mt-1">
<div
class="w-2 h-2 rounded-full"
:class="getStatusDotColor(selectedOrder)"
></div>
<Badge :class="getStatusBadgeClass(selectedOrder)">
{{ getStatusDisplay(selectedOrder) }}
</Badge>
</div>
</div>
<div>
<Label>Date</Label>
<div>{{ formatDate(selectedOrder.time) }}</div>
</div>
<div>
<Label>Total</Label>
<div class="font-bold text-green-700 dark:text-green-300 text-lg">{{ formatSats(selectedOrder.total) }}</div>
</div>
</div>
<!-- Customer Info -->
<div>
<Label>Customer</Label>
<Card class="p-3 mt-2">
<div class="space-y-1 text-sm">
<div v-if="selectedOrder.contact?.nostr">
<span class="font-medium">Nostr:</span>
<span class="ml-2 font-mono">{{ selectedOrder.contact.nostr.slice(0, 16) }}...</span>
</div>
<div v-if="selectedOrder.contact?.email">
<span class="font-medium">Email:</span>
<span class="ml-2">{{ selectedOrder.contact.email }}</span>
</div>
<div v-if="selectedOrder.contact?.phone">
<span class="font-medium">Phone:</span>
<span class="ml-2">{{ selectedOrder.contact.phone }}</span>
</div>
<!-- Fallback: Show public key if no contact info available -->
<div v-if="!selectedOrder.contact?.nostr && !selectedOrder.contact?.email && !selectedOrder.contact?.phone && selectedOrder.public_key">
<span class="font-medium">Public Key:</span>
<span class="ml-2 font-mono text-xs">{{ selectedOrder.public_key.slice(0, 16) }}...{{ selectedOrder.public_key.slice(-8) }}</span>
</div>
<!-- Show message if no customer info at all -->
<div v-if="!selectedOrder.contact?.nostr && !selectedOrder.contact?.email && !selectedOrder.contact?.phone && !selectedOrder.public_key" class="text-muted-foreground">
No customer information available
</div>
</div>
</Card>
</div>
<!-- Shipping Address -->
<div v-if="selectedOrder.address">
<Label>Shipping Address</Label>
<Card class="p-3 mt-2">
<div class="text-sm whitespace-pre-line">{{ selectedOrder.address }}</div>
</Card>
</div>
<!-- Order Items -->
<div>
<Label>Items</Label>
<Card class="mt-2">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>Price</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="(item, index) in getOrderItems(selectedOrder)" :key="index">
<TableCell>{{ item.name }}</TableCell>
<TableCell>{{ formatSats(item.price) }}</TableCell>
<TableCell>{{ item.quantity }}</TableCell>
<TableCell>{{ formatSats(item.price * item.quantity) }}</TableCell>
</TableRow>
<TableRow v-if="selectedOrder.extra?.shipping_cost">
<TableCell colspan="3" class="text-right font-medium">Shipping</TableCell>
<TableCell>{{ formatSats(selectedOrder.extra.shipping_cost_sat) }}</TableCell>
</TableRow>
<TableRow>
<TableCell colspan="3" class="text-right font-bold">Total</TableCell>
<TableCell class="font-bold">{{ formatSats(selectedOrder.total) }}</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
</div>
<!-- Customer Message -->
<div v-if="selectedOrder.message">
<Label>Customer Message</Label>
<Card class="p-3 mt-2">
<div class="text-sm">{{ selectedOrder.message }}</div>
</Card>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showOrderDetails = false">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useToast } from '@/core/composables/useToast'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrmarketAPI, OrderApiResponse } from '../services/nostrmarketAPI'
import type { PaymentService } from '@/core/services/PaymentService'
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label'
import {
Package,
Zap,
Clock,
Truck,
RefreshCw,
MoreHorizontal,
Eye,
CheckCircle,
Loader2,
} from 'lucide-vue-next'
const props = defineProps<{
stallId?: string
}>()
const toast = useToast()
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as PaymentService
// State
const orders = ref<OrderApiResponse[]>([])
const isLoading = ref(false)
const filterStatus = ref<'all' | 'unpaid' | 'paid' | 'shipped'>('all')
const showOrderDetails = ref(false)
const selectedOrder = ref<OrderApiResponse | null>(null)
const currentPage = ref(1)
const itemsPerPage = 10
// Computed properties
const filteredOrders = computed(() => {
switch (filterStatus.value) {
case 'unpaid':
return orders.value.filter(o => !o.paid)
case 'paid':
return orders.value.filter(o => o.paid && !o.shipped)
case 'shipped':
return orders.value.filter(o => o.shipped)
default:
return orders.value
}
})
const totalPages = computed(() => Math.ceil(filteredOrders.value.length / itemsPerPage))
const startIndex = computed(() => (currentPage.value - 1) * itemsPerPage)
const endIndex = computed(() => Math.min(startIndex.value + itemsPerPage, filteredOrders.value.length))
const paginatedOrders = computed(() => {
return filteredOrders.value.slice(startIndex.value, endIndex.value)
})
const orderStats = computed(() => {
const allOrders = orders.value
const paidOrders = allOrders.filter(o => o.paid)
const unpaidOrders = allOrders.filter(o => !o.paid)
const toShipOrders = allOrders.filter(o => o.paid && !o.shipped)
const totalRevenue = paidOrders.reduce((sum, o) => sum + (o.total || 0), 0)
const pendingRevenue = unpaidOrders.reduce((sum, o) => sum + (o.total || 0), 0)
return {
totalOrders: allOrders.length,
paidOrders: paidOrders.length,
unpaidOrders: unpaidOrders.length,
toShipOrders: toShipOrders.length,
totalRevenue,
pendingRevenue,
}
})
// Watch for filter changes to reset pagination
watch(filterStatus, () => {
currentPage.value = 1
})
// Methods
const fetchOrders = async () => {
isLoading.value = true
try {
const inkey = paymentService.getPreferredWalletInvoiceKey()
if (!inkey) {
toast.error('No wallet configured. Please configure a wallet first.')
return
}
let apiOrders: OrderApiResponse[]
if (props.stallId) {
// Try the main orders endpoint first, then filter by stall if needed
apiOrders = await nostrmarketAPI.getOrders(inkey)
// Filter by stall_id if we have orders
if (apiOrders.length > 0) {
apiOrders = apiOrders.filter(order => order.stall_id === props.stallId)
}
// If no orders found or filtering failed, try the stall-specific endpoint as fallback
if (apiOrders.length === 0) {
console.log('🔍 Main orders endpoint returned no results, trying stall-specific endpoint...')
apiOrders = await nostrmarketAPI.getStallOrders(inkey, props.stallId)
}
} else {
apiOrders = await nostrmarketAPI.getOrders(inkey)
}
// Sort by date, newest first
orders.value = apiOrders.sort((a, b) => (b.time || 0) - (a.time || 0))
// Debug: Log first order structure
if (apiOrders.length > 0) {
console.log('🔍 First order structure in MerchantOrders:', {
id: apiOrders[0].id,
contact: apiOrders[0].contact,
items: apiOrders[0].items,
extra: apiOrders[0].extra,
raw_contact_data: apiOrders[0].contact_data,
raw_order_items: apiOrders[0].order_items,
raw_extra_data: apiOrders[0].extra_data
})
// Detailed extra data debugging
if (apiOrders[0].extra) {
console.log('🔍 Extra data details:', {
hasProducts: !!apiOrders[0].extra.products,
productsLength: apiOrders[0].extra.products?.length || 0,
products: apiOrders[0].extra.products,
currency: apiOrders[0].extra.currency,
fullExtra: apiOrders[0].extra
})
} else {
console.log('🔍 No extra data found - attempting manual parse:', {
raw_extra_data: apiOrders[0].extra_data,
typeofExtraData: typeof apiOrders[0].extra_data
})
// Try manual parsing
if (apiOrders[0].extra_data) {
try {
const manualParsed = JSON.parse(apiOrders[0].extra_data)
console.log('🔍 Manual parse result:', manualParsed)
} catch (e) {
console.log('🔍 Manual parse failed:', e)
}
}
}
}
} catch (error) {
console.error('Failed to fetch orders:', error)
toast.error(`Failed to load orders: ${error instanceof Error ? error.message : 'Unknown error occurred'}`)
} finally {
isLoading.value = false
}
}
const refreshOrders = () => {
fetchOrders()
}
const formatSats = (value: number) => {
return `${value.toLocaleString()} sats`
}
const formatDate = (timestamp?: number) => {
if (!timestamp) return 'N/A'
return new Date(timestamp * 1000).toLocaleString()
}
const getCustomerDisplay = (order: OrderApiResponse) => {
// Debug: Log customer data
console.log('🔍 Customer data for order', order.id, {
contact: order.contact,
contact_data: order.contact_data
})
// Try parsed contact first
if (order.contact?.email) return order.contact.email
if (order.contact?.nostr) return `${order.contact.nostr.slice(0, 8)}...`
if (order.contact?.phone) return order.contact.phone
// Fallback: try to parse raw contact_data if contact is missing
if (order.contact_data) {
try {
const rawContact = JSON.parse(order.contact_data)
if (rawContact?.email) return rawContact.email
if (rawContact?.nostr) return `${rawContact.nostr.slice(0, 8)}...`
if (rawContact?.phone) return rawContact.phone
} catch (error) {
console.warn('Failed to parse contact_data:', error)
}
}
// Show public key as fallback
if (order.public_key) return `${order.public_key.slice(0, 8)}...`
return 'Unknown'
}
const getItemsDisplay = (order: OrderApiResponse) => {
// Debug: Log items data
console.log('🔍 Items data for order', order.id, {
items: order.items,
order_items: order.order_items,
extra: order.extra
})
// Best case: Use product names from extra.products with quantities from items
if (order.extra?.products && Array.isArray(order.extra.products) && order.extra.products.length > 0) {
// If we have items with quantities, combine them with product names
if (order.items && Array.isArray(order.items) && order.items.length > 0) {
const itemsDisplay = order.items.map(item => {
const product = order.extra?.products?.find((p: any) => p.id === item.product_id)
const productName = product?.name || item.product_id
return `${item.quantity}x ${productName}`
})
// Return first 2 items with ellipsis if more
if (itemsDisplay.length > 2) {
return `${itemsDisplay.slice(0, 2).join(', ')}... (${itemsDisplay.length} items)`
}
return itemsDisplay.join(', ')
}
// If no items but have products, show product names
const productNames = order.extra.products.map((p: any) => p.name)
if (productNames.length > 2) {
return `${productNames.slice(0, 2).join(', ')}... (${productNames.length} items)`
}
return productNames.join(', ')
}
// Fallback: Try parsed items with just counts
if (order.items && order.items.length > 0) {
const totalItems = order.items.reduce((sum, item) => sum + item.quantity, 0)
return `${totalItems} item${totalItems !== 1 ? 's' : ''}`
}
// Fallback: try to parse raw order_items if items is missing
if (order.order_items) {
try {
const rawItems = JSON.parse(order.order_items)
if (rawItems && rawItems.length > 0) {
const totalItems = rawItems.reduce((sum: number, item: any) => sum + (item.quantity || 0), 0)
return `${totalItems} item${totalItems !== 1 ? 's' : ''}`
}
} catch (error) {
console.warn('Failed to parse order_items:', error)
}
}
return 'No items'
}
const getOrderItems = (order: OrderApiResponse) => {
if (!order.extra?.products) return []
return order.extra.products.map(product => {
const item = order.items?.find(i => i.product_id === product.id)
return {
...product,
quantity: item?.quantity || 0
}
})
}
const getStatusDisplay = (order: OrderApiResponse) => {
if (order.shipped) return 'Shipped'
if (order.paid) return 'Paid'
return 'Unpaid'
}
const getStatusDotColor = (order: OrderApiResponse) => {
if (order.shipped) return 'bg-green-500'
if (order.paid) return 'bg-blue-500'
return 'bg-red-500'
}
const getStatusBadgeClass = (order: OrderApiResponse) => {
if (order.shipped) {
return 'bg-green-100 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800'
}
if (order.paid) {
return 'bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800'
}
return 'bg-red-100 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800'
}
const viewOrderDetails = (order: OrderApiResponse) => {
selectedOrder.value = order
showOrderDetails.value = true
}
const markAsPaid = async (order: OrderApiResponse) => {
try {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
toast.error('Admin key is required to update order status')
return
}
const updated = await nostrmarketAPI.updateOrderStatus(adminKey, {
id: order.id,
paid: true,
})
if (updated) {
// Update local order
const index = orders.value.findIndex(o => o.id === order.id)
if (index !== -1) {
orders.value[index] = updated
}
toast.success(`Order ${order.id.slice(-8)} has been marked as paid`)
}
} catch (error) {
console.error('Failed to update order status:', error)
toast.error(`Failed to update order: ${error instanceof Error ? error.message : 'Unknown error occurred'}`)
}
}
const markAsShipped = async (order: OrderApiResponse) => {
try {
const adminKey = paymentService.getPreferredWalletAdminKey()
if (!adminKey) {
toast.error('Admin key is required to update order status')
return
}
const updated = await nostrmarketAPI.updateOrderStatus(adminKey, {
id: order.id,
shipped: true,
})
if (updated) {
// Update local order
const index = orders.value.findIndex(o => o.id === order.id)
if (index !== -1) {
orders.value[index] = updated
}
toast.success(`Order ${order.id.slice(-8)} has been marked as shipped`)
}
} catch (error) {
console.error('Failed to update order status:', error)
toast.error(`Failed to update order: ${error instanceof Error ? error.message : 'Unknown error occurred'}`)
}
}
// Fetch orders on mount
onMounted(() => {
fetchOrders()
})
</script>

View file

@ -207,15 +207,23 @@
</div>
</div>
<!-- Products Section -->
<!-- Store Tabs -->
<div class="mt-8">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Products</h3>
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
<Plus class="w-4 h-4 mr-2" />
Add Product
</Button>
</div>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="products">Products</TabsTrigger>
<TabsTrigger value="orders">Orders</TabsTrigger>
</TabsList>
<!-- Products Tab -->
<TabsContent value="products" class="mt-6">
<div class="flex items-center justify-between mb-6">
<h3 class="text-xl font-semibold text-foreground">Products</h3>
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
<Plus class="w-4 h-4 mr-2" />
Add Product
</Button>
</div>
<!-- Loading Products -->
<div v-if="isLoadingProducts" class="flex items-center justify-center py-12">
@ -276,6 +284,19 @@
<Badge :variant="product.active ? 'default' : 'secondary'">
{{ product.active ? 'Active' : 'Inactive' }}
</Badge>
<!-- Nostr Sync Status Indicator -->
<div class="flex items-center">
<template v-if="getProductSyncStatus(product.id) === 'confirmed'">
<CheckCircle class="w-4 h-4 text-green-600" title="Confirmed on Nostr" />
</template>
<template v-else-if="getProductSyncStatus(product.id) === 'pending'">
<Clock class="w-4 h-4 text-blue-600 animate-pulse" title="Awaiting Nostr confirmation" />
</template>
<template v-else>
<AlertCircle class="w-4 h-4 text-gray-400" title="Sync status unknown" />
</template>
</div>
</div>
</div>
@ -292,18 +313,54 @@
</div>
<!-- Product Actions -->
<div class="flex justify-end pt-2 border-t">
<Button
<div class="flex justify-between pt-2 border-t">
<div class="flex gap-2">
<Button
@click="deleteProduct(product)"
variant="ghost"
size="sm"
class="text-red-600 hover:text-red-700 hover:bg-red-50"
:disabled="isDeletingProduct"
>
<div class="flex items-center">
<Trash2 class="w-4 h-4 mr-1" />
{{ isDeletingProduct && deletingProductId === product.id ? 'Deleting...' : 'Delete' }}
</div>
</Button>
<Button
@click="resendProduct(product)"
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
:disabled="isResendingProduct"
>
<div class="flex items-center">
<Send class="w-4 h-4 mr-1" />
{{ isResendingProduct && resendingProductId === product.id ? 'Re-sending...' : 'Re-send' }}
</div>
</Button>
</div>
<Button
@click="editProduct(product)"
variant="ghost"
variant="ghost"
size="sm"
>
Edit
<div class="flex items-center">
<Edit class="w-4 h-4 mr-1" />
Edit
</div>
</Button>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- Orders Tab -->
<TabsContent value="orders" class="mt-6">
<MerchantOrders :stall-id="activeStallId || undefined" />
</TabsContent>
</Tabs>
</div>
</div>
</div>
@ -325,6 +382,16 @@
@created="onProductCreated"
@updated="onProductUpdated"
/>
<!-- Delete Confirm Dialog -->
<DeleteConfirmDialog
:is-open="showDeleteConfirmDialog"
:product-name="productToDelete?.name || ''"
:is-deleting="isDeletingProduct && deletingProductId === productToDelete?.id"
@confirm="confirmDeleteProduct"
@cancel="cancelDeleteProduct"
@update:is-open="showDeleteConfirmDialog = $event"
/>
</div>
</template>
@ -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<Product[]>([])
const isLoadingProducts = ref(false)
// Product action state
const isDeletingProduct = ref(false)
const deletingProductId = ref<string | null>(null)
const isResendingProduct = ref(false)
const resendingProductId = ref<string | null>(null)
// Nostr sync tracking
const pendingNostrConfirmation = ref<Map<string, number>>(new Map()) // productId -> timestamp
const confirmedOnNostr = ref<Set<string>>(new Set())
// Tab management
const activeTab = ref<string>('products')
// Dialog state
const showCreateStoreDialog = ref(false)
const showCreateProductDialog = ref(false)
const showDeleteConfirmDialog = ref(false)
const editingProduct = ref<Product | null>(null)
const productToDelete = ref<Product | null>(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')

View file

@ -1,11 +1,11 @@
<template>
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
<!-- Product Image -->
<div class="relative">
<!-- Show actual image if available -->
<!-- Product Image with Cycling and Progressive Loading -->
<div class="relative group">
<!-- Show actual image with progressive loading if available -->
<ProgressiveImage
v-if="product.images?.[0]"
:src="product.images[0]"
v-if="currentImage"
:src="currentImage"
:alt="product.name"
container-class="w-full h-48 bg-muted/50"
image-class="w-full h-48 object-cover"
@ -23,7 +23,43 @@
<span class="text-xs text-muted-foreground">No image available</span>
</div>
</div>
<!-- Image Navigation Arrows (show on hover if multiple images) -->
<div v-if="hasMultipleImages" class="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<!-- Previous Image Button -->
<Button
@click="previousImage"
type="button"
size="sm"
variant="secondary"
class="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white border-0 h-8 w-8 p-0"
>
<ChevronLeft class="w-4 h-4" />
</Button>
<!-- Next Image Button -->
<Button
@click="nextImage"
type="button"
size="sm"
variant="secondary"
class="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white border-0 h-8 w-8 p-0"
>
<ChevronRight class="w-4 h-4" />
</Button>
</div>
<!-- Image Indicators (dots) -->
<div v-if="hasMultipleImages" class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex gap-1">
<div
v-for="(_, index) in productImages"
:key="index"
@click="setCurrentImage(index)"
class="w-2 h-2 rounded-full cursor-pointer transition-colors duration-200"
:class="currentImageIndex === index ? 'bg-white' : 'bg-white/50 hover:bg-white/70'"
/>
</div>
<!-- Add to Cart Button -->
<Button
@click="addToCart"
@ -33,7 +69,7 @@
>
<ShoppingCart class="w-4 h-4" />
</Button>
<!-- Out of Stock Badge -->
<Badge
v-if="product.quantity < 1"
@ -115,12 +151,12 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed, watch } from 'vue'
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import { ShoppingCart, Package } from 'lucide-vue-next'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import type { Product } from '@/modules/market/stores/market'
interface Props {
@ -136,6 +172,53 @@ const emit = defineEmits<{
}>()
const imageError = ref(false)
const currentImageIndex = ref(0)
// Computed properties for image cycling
const productImages = computed(() => {
if (!props.product.images || props.product.images.length === 0) {
return []
}
return props.product.images.filter(img => img && img.trim() !== '')
})
const hasMultipleImages = computed(() => productImages.value.length > 1)
const currentImage = computed(() => {
if (productImages.value.length === 0) {
return null
}
return productImages.value[currentImageIndex.value]
})
// Image cycling methods
const nextImage = (event?: Event) => {
event?.stopPropagation()
if (productImages.value.length > 0) {
currentImageIndex.value = (currentImageIndex.value + 1) % productImages.value.length
}
}
const previousImage = (event?: Event) => {
event?.stopPropagation()
if (productImages.value.length > 0) {
currentImageIndex.value = currentImageIndex.value === 0
? productImages.value.length - 1
: currentImageIndex.value - 1
}
}
const setCurrentImage = (index: number, event?: Event) => {
event?.stopPropagation()
if (index >= 0 && index < productImages.value.length) {
currentImageIndex.value = index
}
}
// Reset image index when product changes
watch(() => props.product.id, () => {
currentImageIndex.value = 0
})
const addToCart = () => {
emit('add-to-cart', props.product)

View file

@ -1,280 +0,0 @@
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<!-- Close Button -->
<DialogClose class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogClose>
<div class="grid gap-6 md:grid-cols-2">
<!-- Product Images -->
<div class="space-y-4">
<!-- Main Image -->
<div class="aspect-square rounded-lg overflow-hidden bg-gray-100">
<ProgressiveImage
v-if="currentImage"
:src="currentImage"
:alt="product.name"
container-class="w-full h-full"
image-class="w-full h-full object-cover"
:blur-radius="12"
:transition-duration="500"
loading="lazy"
:show-loading-indicator="true"
@error="handleImageError"
/>
<div v-else class="w-full h-full bg-gradient-to-br from-muted/50 to-muted flex items-center justify-center">
<div class="text-center">
<Package class="w-24 h-24 mx-auto text-muted-foreground mb-4" />
<span class="text-sm text-muted-foreground">No image available</span>
</div>
</div>
</div>
<!-- Image Thumbnails -->
<div v-if="productImages.length > 1" class="flex gap-2 overflow-x-auto">
<button
v-for="(image, index) in productImages"
:key="index"
@click="currentImageIndex = index"
class="relative w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
:class="currentImageIndex === index ? 'border-primary' : 'border-gray-200 hover:border-gray-400'"
>
<ProgressiveImage
:src="image"
:alt="`${product.name} - Image ${index + 1}`"
container-class="w-full h-full"
image-class="w-full h-full object-cover"
:blur-radius="6"
:transition-duration="300"
loading="lazy"
/>
</button>
</div>
</div>
<!-- Product Details -->
<div class="space-y-6">
<!-- Title and Price -->
<div>
<h2 class="text-3xl font-bold mb-2">{{ product.name }}</h2>
<div class="flex items-baseline gap-4">
<span class="text-3xl font-bold text-green-600">
{{ formatPrice(product.price, product.currency) }}
</span>
<Badge v-if="product.quantity < 1" variant="destructive">
Out of Stock
</Badge>
<Badge v-else-if="product.quantity <= 5" variant="outline">
Only {{ product.quantity }} left
</Badge>
<Badge v-else variant="secondary">
In Stock
</Badge>
</div>
</div>
<!-- Stall Info -->
<div class="flex items-center gap-2 pb-4 border-b">
<Store class="w-4 h-4 text-gray-500" />
<span class="text-sm text-gray-600">Sold by</span>
<span class="font-medium">{{ product.stallName }}</span>
</div>
<!-- Description -->
<div v-if="product.description">
<h3 class="font-semibold mb-2">Description</h3>
<p class="text-gray-600 whitespace-pre-wrap">{{ product.description }}</p>
</div>
<!-- Categories -->
<div v-if="product.categories && product.categories.length > 0">
<h3 class="font-semibold mb-2">Categories</h3>
<div class="flex flex-wrap gap-2">
<Badge
v-for="category in product.categories"
:key="category"
variant="secondary"
>
{{ category }}
</Badge>
</div>
</div>
<!-- Add to Cart Section -->
<div class="space-y-4 pt-6 border-t">
<div class="flex items-center gap-4">
<Label for="quantity" class="text-sm font-medium">
Quantity:
</Label>
<div class="flex items-center gap-2">
<Button
@click="decrementQuantity"
:disabled="quantity <= 1"
size="sm"
variant="outline"
class="h-8 w-8 p-0"
>
<Minus class="h-4 w-4" />
</Button>
<Input
id="quantity"
v-model.number="quantity"
type="number"
:min="1"
:max="product.quantity || 999"
class="w-16 h-8 text-center"
/>
<Button
@click="incrementQuantity"
:disabled="quantity >= (product.quantity || 999)"
size="sm"
variant="outline"
class="h-8 w-8 p-0"
>
<Plus class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex gap-3">
<Button
@click="handleAddToCart"
:disabled="product.quantity < 1"
class="flex-1"
>
<ShoppingCart class="w-4 h-4 mr-2" />
Add to Cart
</Button>
<Button
@click="handleClose"
variant="outline"
class="flex-1"
>
Continue Shopping
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Dialog,
DialogContent,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import { Package, ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
import { useToast } from '@/core/composables/useToast'
import type { Product } from '../types/market'
interface Props {
product: Product
isOpen: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
'add-to-cart': [product: Product, quantity: number]
}>()
const toast = useToast()
// Local state
const quantity = ref(1)
const currentImageIndex = ref(0)
const imageLoadError = ref(false)
// Computed properties
const productImages = computed(() => {
if (!props.product.images || props.product.images.length === 0) {
return []
}
return props.product.images.filter(img => img && img.trim() !== '')
})
const currentImage = computed(() => {
if (productImages.value.length === 0) {
return null
}
return productImages.value[currentImageIndex.value]
})
// Methods
const formatPrice = (price: number, currency: string) => {
if (currency === 'sat' || currency === 'sats') {
return `${price.toLocaleString('en-US')} sats`
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase()
}).format(price)
}
const incrementQuantity = () => {
const max = props.product.quantity || 999
if (quantity.value < max) {
quantity.value++
}
}
const decrementQuantity = () => {
if (quantity.value > 1) {
quantity.value--
}
}
const handleAddToCart = () => {
emit('add-to-cart', props.product, quantity.value)
toast.success(`Added ${quantity.value} ${props.product.name} to cart`)
handleClose()
}
const handleClose = () => {
emit('close')
}
const handleImageError = () => {
imageLoadError.value = true
}
// Reset state when dialog opens/closes
watch(() => props.isOpen, (newVal) => {
if (newVal) {
quantity.value = 1
currentImageIndex.value = 0
imageLoadError.value = false
}
})
// Reset image index when product changes
watch(() => props.product?.id, () => {
currentImageIndex.value = 0
imageLoadError.value = false
})
</script>
<style scoped>
/* Hide number input spinner buttons */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>

View file

@ -27,23 +27,15 @@
/>
</div>
<!-- Product Detail Dialog - Now managed internally -->
<ProductDetailDialog
v-if="selectedProduct"
:product="selectedProduct"
:isOpen="showProductDetail"
@close="closeProductDetail"
@add-to-cart="handleDialogAddToCart"
/>
</LoadingErrorState>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Package as EmptyIcon } from 'lucide-vue-next'
import ProductCard from './ProductCard.vue'
import ProductDetailDialog from './ProductDetailDialog.vue'
import LoadingErrorState from './LoadingErrorState.vue'
import type { Product } from '../types/market'
@ -99,29 +91,15 @@ const gridClasses = computed(() => {
return classes.join(' ')
})
// Internal state for product detail dialog
const showProductDetail = ref(false)
const selectedProduct = ref<Product | null>(null)
const router = useRouter()
// Handle view details internally
// Handle view details by navigating to product page
const handleViewDetails = (product: Product) => {
selectedProduct.value = product
showProductDetail.value = true
}
const closeProductDetail = () => {
showProductDetail.value = false
selectedProduct.value = null
router.push(`/market/product/${product.id}`)
}
// Handle add to cart from product card (quick add, quantity 1)
const handleAddToCart = (product: Product) => {
emit('add-to-cart', product, 1)
}
// Handle add to cart from dialog (with custom quantity)
const handleDialogAddToCart = (product: Product, quantity: number) => {
emit('add-to-cart', product, quantity)
closeProductDetail()
}
</script>

View file

@ -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
}

View file

@ -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[],

View file

@ -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<ProductApiResponse> {
// 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<ProductApiResponse>(
'/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<OrderApiResponse[]> {
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<OrderApiResponse[]>(
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<OrderApiResponse[]> {
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<OrderApiResponse[]>(
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<OrderApiResponse | null> {
try {
const order = await this.request<OrderApiResponse>(
`/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<OrderApiResponse | null> {
try {
const order = await this.request<OrderApiResponse>(
`/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
}
}
}

View file

@ -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<string> {
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<string> {
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<string, string>, // stallId -> eventId
products: Record<string, string> // productId -> eventId
}> {
const results = {
stalls: {} as Record<string, string>,
products: {} as Record<string, string>
}
// 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
}

View file

@ -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<LightningInvoice | null> => {
@ -916,6 +873,5 @@ export const useMarketStore = defineStore('market', () => {
saveOrdersToStorage,
loadOrdersFromStorage,
clearOrdersForUserChange,
publishToNostrmarket
}
})

View file

@ -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

View file

@ -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}`)

View file

@ -0,0 +1,314 @@
<template>
<div class="container mx-auto px-4 py-8 max-w-6xl">
<!-- Back Navigation -->
<div class="mb-6">
<Button
@click="goBack"
variant="outline"
class="flex items-center gap-2"
>
<ArrowLeft class="w-4 h-4" />
Back
</Button>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p class="text-muted-foreground">Loading product details...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex justify-center items-center min-h-[400px]">
<div class="text-center">
<p class="text-destructive mb-4">{{ error }}</p>
<Button @click="loadProduct" variant="outline">
Try Again
</Button>
</div>
</div>
<!-- Product Content -->
<div v-else-if="product" class="grid gap-8 md:grid-cols-2">
<!-- Product Images with Lightbox -->
<div class="space-y-4">
<ImageViewer
:images="productImages"
:alt="product.name"
container-class="aspect-square rounded-lg overflow-hidden bg-gray-100"
image-class="w-full h-full object-cover"
:blur-radius="12"
:transition-duration="500"
:show-thumbnails="productImages.length > 1"
:show-lightbox="true"
:show-badge="productImages.length > 1"
:show-cycle-controls="productImages.length > 1"
@error="handleImageError"
@image-change="handleImageChange"
@lightbox-open="handleLightboxOpen"
@lightbox-close="handleLightboxClose"
/>
</div>
<!-- Product Details -->
<div class="space-y-6">
<!-- Title and Price -->
<div>
<h1 class="text-4xl font-bold mb-4">{{ product.name }}</h1>
<div class="flex items-baseline gap-4">
<span class="text-4xl font-bold text-green-600">
{{ formatPrice(product.price, product.currency) }}
</span>
<Badge v-if="product.quantity < 1" variant="destructive">
Out of Stock
</Badge>
<Badge v-else-if="product.quantity <= 5" variant="outline">
Only {{ product.quantity }} left
</Badge>
<Badge v-else variant="secondary">
In Stock
</Badge>
</div>
</div>
<!-- Stall Info -->
<div class="flex items-center gap-2 pb-4 border-b">
<Store class="w-4 h-4 text-gray-500" />
<span class="text-sm text-gray-600">Sold by</span>
<span class="font-medium">{{ product.stallName }}</span>
</div>
<!-- Description -->
<div v-if="product.description">
<h3 class="font-semibold mb-2">Description</h3>
<p class="text-gray-600 whitespace-pre-wrap">{{ product.description }}</p>
</div>
<!-- Categories -->
<div v-if="product.categories && product.categories.length > 0">
<h3 class="font-semibold mb-2">Categories</h3>
<div class="flex flex-wrap gap-2">
<Badge
v-for="category in product.categories"
:key="category"
variant="secondary"
>
{{ category }}
</Badge>
</div>
</div>
<!-- Add to Cart Section -->
<div class="space-y-4 pt-6 border-t">
<div class="flex items-center gap-4">
<Label for="quantity" class="text-sm font-medium">
Quantity:
</Label>
<div class="flex items-center gap-2">
<Button
@click="decrementQuantity"
:disabled="quantity <= 1"
size="sm"
variant="outline"
class="h-8 w-8 p-0"
>
<Minus class="h-4 w-4" />
</Button>
<Input
id="quantity"
v-model.number="quantity"
type="number"
:min="1"
:max="product.quantity || 999"
class="w-16 h-8 text-center"
/>
<Button
@click="incrementQuantity"
:disabled="quantity >= (product.quantity || 999)"
size="sm"
variant="outline"
class="h-8 w-8 p-0"
>
<Plus class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex gap-3">
<Button
@click="handleAddToCart"
:disabled="product.quantity < 1"
class="flex-1"
>
<ShoppingCart class="w-4 h-4 mr-2" />
Add to Cart
</Button>
<Button
@click="goBack"
variant="outline"
class="flex-1"
>
Continue Shopping
</Button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import ImageViewer from '@/components/ui/image/ImageViewer.vue'
import { ShoppingCart, Store, Plus, Minus, ArrowLeft } from 'lucide-vue-next'
import { useToast } from '@/core/composables/useToast'
import type { Product } from '../types/market'
import { useMarketStore } from '../stores/market'
const route = useRoute()
const router = useRouter()
const toast = useToast()
// Store
const marketStore = useMarketStore()
// Local state
const product = ref<Product | null>(null)
const isLoading = ref(true)
const error = ref<string | null>(null)
const quantity = ref(1)
const imageLoadError = ref(false)
// Computed properties
const productImages = computed(() => {
if (!product.value?.images || product.value.images.length === 0) {
return []
}
return product.value.images.filter(img => img && img.trim() !== '')
})
// Methods
const formatPrice = (price: number, currency: string) => {
if (currency === 'sat' || currency === 'sats') {
return `${price.toLocaleString('en-US')} sats`
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase()
}).format(price)
}
const incrementQuantity = () => {
const max = product.value?.quantity || 999
if (quantity.value < max) {
quantity.value++
}
}
const decrementQuantity = () => {
if (quantity.value > 1) {
quantity.value--
}
}
const handleAddToCart = () => {
if (!product.value) return
// Add to stall cart using market store
marketStore.addToStallCart(product.value, quantity.value)
toast.success(`Added ${quantity.value} ${product.value.name} to cart`)
// Optionally navigate to cart or stay on page
// router.push('/cart')
}
const goBack = () => {
// Navigate back to previous page or to market if no history
if (window.history.length > 1) {
router.back()
} else {
router.push('/market')
}
}
const loadProduct = async () => {
try {
isLoading.value = true
error.value = null
const productId = route.params.productId as string
if (!productId) {
throw new Error('Product ID is required')
}
// Find product in the market store
const productData = marketStore.products.find(p => p.id === productId)
if (!productData) {
throw new Error('Product not found')
}
// Create a mutable copy to satisfy type requirements
product.value = {
...productData,
images: productData.images ? [...productData.images] : undefined,
categories: productData.categories ? [...productData.categories] : undefined
}
// Update page title
document.title = `${productData.name} - Product Details`
} catch (err) {
console.error('Failed to load product:', err)
error.value = err instanceof Error ? err.message : 'Failed to load product'
} finally {
isLoading.value = false
}
}
const handleImageError = (error: Event) => {
imageLoadError.value = true
console.warn('Image failed to load:', error)
}
const handleImageChange = (index: number, src: string) => {
// Optional: Handle image change events if needed
console.log('Image changed to index:', index, 'src:', src)
}
const handleLightboxOpen = (index: number) => {
// Optional: Handle lightbox open events if needed
console.log('Lightbox opened at index:', index)
}
const handleLightboxClose = () => {
// Optional: Handle lightbox close events if needed
console.log('Lightbox closed')
}
// Load product on mount
onMounted(() => {
loadProduct()
})
</script>
<style scoped>
/* Hide number input spinner buttons */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>