Compare commits
10 commits
f7405bc26e
...
c90def94a7
| Author | SHA1 | Date | |
|---|---|---|---|
| c90def94a7 | |||
| 08b172ab34 | |||
| 0da23e9332 | |||
| 0447549fa5 | |||
| b69be281f3 | |||
| 98934ed61d | |||
| 3742937aea | |||
| ca0ac2b9ad | |||
| 3aec5bbdb3 | |||
| bff158cb74 |
45 changed files with 3600 additions and 948 deletions
33
CLAUDE.md
33
CLAUDE.md
|
|
@ -33,6 +33,12 @@ The application uses a plugin-based modular architecture with dependency injecti
|
|||
- **Events Module** (`src/modules/events/`) - Event ticketing with Lightning payments
|
||||
- **Market Module** (`src/modules/market/`) - Nostr marketplace functionality
|
||||
|
||||
**IMPORTANT - Market Event Publishing Strategy:**
|
||||
- **LNbits "nostrmarket" extension handles ALL market event publishing** (merchants, stalls, products) to Nostr relays
|
||||
- **Web-app does NOT publish** merchant/stall/product events - only processes incoming events from relays
|
||||
- **Exception: Checkout/Order events** - Web-app publishes order events directly to Nostr during checkout process
|
||||
- This division ensures consistency and prevents duplicate publishing while allowing real-time order placement
|
||||
|
||||
**Module Configuration:**
|
||||
- Modules are configured in `src/app.config.ts`
|
||||
- Each module can be enabled/disabled and configured independently
|
||||
|
|
@ -730,6 +736,33 @@ export function useMyModule() {
|
|||
- **ALWAYS extend BaseService for module services**
|
||||
- **NEVER create direct dependencies between modules**
|
||||
|
||||
### **⚠️ CRITICAL: JavaScript Falsy Value Bug Prevention**
|
||||
|
||||
**ALWAYS use nullish coalescing (`??`) instead of logical OR (`||`) for numeric defaults:**
|
||||
|
||||
```typescript
|
||||
// ❌ WRONG: Treats 0 as falsy, defaults to 1 even when quantity is validly 0
|
||||
quantity: productData.quantity || 1
|
||||
|
||||
// ✅ CORRECT: Only defaults to 1 when quantity is null or undefined
|
||||
quantity: productData.quantity ?? 1
|
||||
```
|
||||
|
||||
**Why this matters:**
|
||||
- JavaScript falsy values include: `false`, `0`, `""`, `null`, `undefined`, `NaN`
|
||||
- Using `||` for defaults will incorrectly override valid `0` values
|
||||
- This caused a critical bug where products with quantity `0` displayed as quantity `1`
|
||||
- The `??` operator only triggers for `null` and `undefined`, preserving valid `0` values
|
||||
|
||||
**Common scenarios where this bug occurs:**
|
||||
- Product quantities, prices, counters (any numeric value where 0 is valid)
|
||||
- Boolean flags where `false` is a valid state
|
||||
- Empty strings that should be preserved vs. undefined strings
|
||||
|
||||
**Rule of thumb:**
|
||||
- Use `||` only when `0`, `false`, or `""` should trigger the default
|
||||
- Use `??` when only `null`/`undefined` should trigger the default (most cases)
|
||||
|
||||
**Build Configuration:**
|
||||
- Vite config includes PWA, image optimization, and bundle analysis
|
||||
- Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn)
|
||||
|
|
|
|||
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
241
src/components/ui/image/ImageLightbox.vue
Normal file
241
src/components/ui/image/ImageLightbox.vue
Normal 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>
|
||||
334
src/components/ui/image/ImageViewer.vue
Normal file
334
src/components/ui/image/ImageViewer.vue
Normal 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>
|
||||
|
|
@ -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: () => {
|
||||
187
src/components/ui/image/composables/useImageLightbox.ts
Normal file
187
src/components/ui/image/composables/useImageLightbox.ts
Normal 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)
|
||||
}
|
||||
10
src/components/ui/image/index.ts
Normal file
10
src/components/ui/image/index.ts
Normal 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'
|
||||
16
src/components/ui/table/Table.vue
Normal file
16
src/components/ui/table/Table.vue
Normal 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>
|
||||
17
src/components/ui/table/TableBody.vue
Normal file
17
src/components/ui/table/TableBody.vue
Normal 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>
|
||||
17
src/components/ui/table/TableCaption.vue
Normal file
17
src/components/ui/table/TableCaption.vue
Normal 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>
|
||||
22
src/components/ui/table/TableCell.vue
Normal file
22
src/components/ui/table/TableCell.vue
Normal 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>
|
||||
34
src/components/ui/table/TableEmpty.vue
Normal file
34
src/components/ui/table/TableEmpty.vue
Normal 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>
|
||||
17
src/components/ui/table/TableFooter.vue
Normal file
17
src/components/ui/table/TableFooter.vue
Normal 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>
|
||||
17
src/components/ui/table/TableHead.vue
Normal file
17
src/components/ui/table/TableHead.vue
Normal 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>
|
||||
17
src/components/ui/table/TableHeader.vue
Normal file
17
src/components/ui/table/TableHeader.vue
Normal 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>
|
||||
17
src/components/ui/table/TableRow.vue
Normal file
17
src/components/ui/table/TableRow.vue
Normal 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>
|
||||
9
src/components/ui/table/index.ts
Normal file
9
src/components/ui/table/index.ts
Normal 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"
|
||||
10
src/components/ui/table/utils.ts
Normal file
10
src/components/ui/table/utils.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
77
src/modules/chat/composables/useNotifications.ts
Normal file
77
src/modules/chat/composables/useNotifications.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
202
src/modules/chat/stores/notification.ts
Normal file
202
src/modules/chat/stores/notification.ts
Normal 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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
85
src/modules/market/components/DeleteConfirmDialog.vue
Normal file
85
src/modules/market/components/DeleteConfirmDialog.vue
Normal 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>
|
||||
744
src/modules/market/components/MerchantOrders.vue
Normal file
744
src/modules/market/components/MerchantOrders.vue
Normal 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>
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
314
src/modules/market/views/ProductDetailPage.vue
Normal file
314
src/modules/market/views/ProductDetailPage.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue