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
|
- **Events Module** (`src/modules/events/`) - Event ticketing with Lightning payments
|
||||||
- **Market Module** (`src/modules/market/`) - Nostr marketplace functionality
|
- **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:**
|
**Module Configuration:**
|
||||||
- Modules are configured in `src/app.config.ts`
|
- Modules are configured in `src/app.config.ts`
|
||||||
- Each module can be enabled/disabled and configured independently
|
- Each module can be enabled/disabled and configured independently
|
||||||
|
|
@ -730,6 +736,33 @@ export function useMyModule() {
|
||||||
- **ALWAYS extend BaseService for module services**
|
- **ALWAYS extend BaseService for module services**
|
||||||
- **NEVER create direct dependencies between modules**
|
- **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:**
|
**Build Configuration:**
|
||||||
- Vite config includes PWA, image optimization, and bundle analysis
|
- Vite config includes PWA, image optimization, and bundle analysis
|
||||||
- Manual chunking strategy for vendor libraries (vue-vendor, ui-vendor, shadcn)
|
- 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",
|
"name": "aio-shadcn-vite",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/components": "^12.5.0",
|
"@vueuse/components": "^12.5.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
|
|
@ -4950,9 +4950,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/table-core": {
|
"node_modules/@tanstack/table-core": {
|
||||||
"version": "8.21.2",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
|
||||||
"integrity": "sha512-uvXk/U4cBiFMxt+p9/G7yUWI/UbHYbyghLCjlpWZ3mLeIZiUBSKcUnw9UnKkdRz7Z/N4UBuFLWQdJCjUe7HjvA==",
|
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|
@ -4973,12 +4973,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/vue-table": {
|
"node_modules/@tanstack/vue-table": {
|
||||||
"version": "8.21.2",
|
"version": "8.21.3",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/vue-table/-/vue-table-8.21.3.tgz",
|
||||||
"integrity": "sha512-KBgOWxha/x4m1EdhVWxOpqHb661UjqAxzPcmXR3QiA7aShZ547x19Gw0UJX9we+m+tVcPuLRZ61JsYW47QZFfQ==",
|
"integrity": "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/table-core": "8.21.2"
|
"@tanstack/table-core": "8.21.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"make": "electron-forge make"
|
"make": "electron-forge make"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/vue-table": "^8.21.2",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vee-validate/zod": "^4.15.1",
|
"@vee-validate/zod": "^4.15.1",
|
||||||
"@vueuse/components": "^12.5.0",
|
"@vueuse/components": "^12.5.0",
|
||||||
"@vueuse/core": "^12.8.2",
|
"@vueuse/core": "^12.8.2",
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@ export const appConfig: AppConfig = {
|
||||||
config: {
|
config: {
|
||||||
maxMessages: 500,
|
maxMessages: 500,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
showTimestamps: true
|
showTimestamps: true,
|
||||||
|
notifications: {
|
||||||
|
enabled: true,
|
||||||
|
soundEnabled: false,
|
||||||
|
wildcardSupport: true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: {
|
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-loaded': isLoaded,
|
||||||
'progressive-image-error': hasError
|
'progressive-image-error': hasError
|
||||||
}
|
}
|
||||||
]" :loading="loading" @load="handleLoad" @error="handleError" /> -->
|
]" :loading="loading" @load="handleLoad" @error="handleError" />
|
||||||
|
|
||||||
<!-- Loading indicator (optional) -->
|
<!-- Loading indicator (optional) -->
|
||||||
<div v-if="showLoadingIndicator && !isLoaded && !hasError" class="progressive-image-loading-indicator">
|
<div v-if="showLoadingIndicator && !isLoaded && !hasError" class="progressive-image-loading-indicator">
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { Package } from 'lucide-vue-next'
|
import { Package } from 'lucide-vue-next'
|
||||||
|
|
||||||
interface Props {
|
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
|
// Expose methods for parent components
|
||||||
defineExpose({
|
defineExpose({
|
||||||
reload: () => {
|
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 components
|
||||||
import ImageUpload from './components/ImageUpload.vue'
|
import ImageUpload from './components/ImageUpload.vue'
|
||||||
import ImageDisplay from './components/ImageDisplay.vue'
|
|
||||||
|
|
||||||
// Create service instances
|
// Create service instances
|
||||||
const invoiceService = new InvoiceService()
|
const invoiceService = new InvoiceService()
|
||||||
|
|
@ -143,8 +142,7 @@ export const baseModule: ModulePlugin = {
|
||||||
|
|
||||||
// Export components for use by other modules
|
// Export components for use by other modules
|
||||||
components: {
|
components: {
|
||||||
ImageUpload,
|
ImageUpload
|
||||||
ImageDisplay
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -415,22 +415,8 @@ const currentMessages = computed(() => {
|
||||||
return chat.currentMessages.value
|
return chat.currentMessages.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sort peers by unread count and name
|
// NOTE: peers is already sorted correctly by the chat service (by activity: lastSent/lastReceived)
|
||||||
const sortedPeers = computed(() => {
|
// We use it directly without re-sorting here
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fuzzy search for peers
|
// Fuzzy search for peers
|
||||||
// This integrates the useFuzzySearch composable to provide intelligent search functionality
|
// This integrates the useFuzzySearch composable to provide intelligent search functionality
|
||||||
|
|
@ -441,7 +427,7 @@ const {
|
||||||
isSearching,
|
isSearching,
|
||||||
resultCount,
|
resultCount,
|
||||||
clearSearch
|
clearSearch
|
||||||
} = useFuzzySearch(sortedPeers, {
|
} = useFuzzySearch(peers, {
|
||||||
fuseOptions: {
|
fuseOptions: {
|
||||||
keys: [
|
keys: [
|
||||||
{ name: 'name', weight: 0.7 }, // Name has higher weight for better UX
|
{ name: 'name', weight: 0.7 }, // Name has higher weight for better UX
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,12 @@ export function useChat() {
|
||||||
return chatService.addPeer(pubkey, name)
|
return chatService.addPeer(pubkey, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAsRead = (peerPubkey: string) => {
|
const markAsRead = (peerPubkey: string, timestamp?: number) => {
|
||||||
chatService.markAsRead(peerPubkey)
|
chatService.markAsRead(peerPubkey, timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const markAllChatsAsRead = () => {
|
||||||
|
chatService.markAllChatsAsRead()
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshPeers = async () => {
|
const refreshPeers = async () => {
|
||||||
|
|
@ -81,19 +85,20 @@ export function useChat() {
|
||||||
refreshPeersError: refreshPeersOp.error,
|
refreshPeersError: refreshPeersOp.error,
|
||||||
isLoading: computed(() => asyncOps.isAnyLoading()),
|
isLoading: computed(() => asyncOps.isAnyLoading()),
|
||||||
error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value),
|
error: computed(() => sendMessageOp.error.value || refreshPeersOp.error.value),
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
peers,
|
peers,
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
isReady,
|
isReady,
|
||||||
currentMessages,
|
currentMessages,
|
||||||
currentPeer,
|
currentPeer,
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
selectPeer,
|
selectPeer,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
addPeer,
|
addPeer,
|
||||||
markAsRead,
|
markAsRead,
|
||||||
|
markAllChatsAsRead,
|
||||||
refreshPeers
|
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,
|
maxMessages: 500,
|
||||||
autoScroll: true,
|
autoScroll: true,
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
notificationsEnabled: true,
|
notifications: {
|
||||||
soundEnabled: false,
|
enabled: true,
|
||||||
|
soundEnabled: false,
|
||||||
|
wildcardSupport: true
|
||||||
|
},
|
||||||
...options?.config
|
...options?.config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import { ref, computed } from 'vue'
|
||||||
import { eventBus } from '@/core/event-bus'
|
import { eventBus } from '@/core/event-bus'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
import { BaseService } from '@/core/base/BaseService'
|
||||||
import { nip04, finalizeEvent, type Event, type EventTemplate } from 'nostr-tools'
|
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 { getAuthToken } from '@/lib/config/lnbits'
|
||||||
import { config } from '@/lib/config'
|
import { config } from '@/lib/config'
|
||||||
|
import { useChatNotificationStore } from '../stores/notification'
|
||||||
export class ChatService extends BaseService {
|
export class ChatService extends BaseService {
|
||||||
// Service metadata
|
// Service metadata
|
||||||
protected readonly metadata = {
|
protected readonly metadata = {
|
||||||
|
|
@ -21,20 +22,35 @@ export class ChatService extends BaseService {
|
||||||
private visibilityUnsubscribe?: () => void
|
private visibilityUnsubscribe?: () => void
|
||||||
private isFullyInitialized = false
|
private isFullyInitialized = false
|
||||||
private authCheckInterval?: ReturnType<typeof setInterval>
|
private authCheckInterval?: ReturnType<typeof setInterval>
|
||||||
|
private notificationStore?: ReturnType<typeof useChatNotificationStore>
|
||||||
|
|
||||||
constructor(config: ChatConfig) {
|
constructor(config: ChatConfig) {
|
||||||
super()
|
super()
|
||||||
this.config = config
|
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
|
// Register market message handler for forwarding market-related DMs
|
||||||
setMarketMessageHandler(handler: (event: any) => Promise<void>) {
|
setMarketMessageHandler(handler: (event: any) => Promise<void>) {
|
||||||
this.marketMessageHandler = handler
|
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)
|
* Service-specific initialization (called by BaseService)
|
||||||
*/
|
*/
|
||||||
protected async onInitialize(): Promise<void> {
|
protected async onInitialize(): Promise<void> {
|
||||||
this.debug('Chat service onInitialize called')
|
this.debug('Chat service onInitialize called')
|
||||||
|
|
||||||
// Check both injected auth service AND global auth composable
|
// Check both injected auth service AND global auth composable
|
||||||
// Removed dual auth import
|
// Removed dual auth import
|
||||||
const hasAuthService = this.authService?.user?.value?.pubkey
|
const hasAuthService = this.authService?.user?.value?.pubkey
|
||||||
|
|
@ -83,6 +99,13 @@ export class ChatService extends BaseService {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.debug('Completing chat service initialization...')
|
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
|
// Load peers from storage first
|
||||||
this.loadPeersFromStorage()
|
this.loadPeersFromStorage()
|
||||||
// Load peers from API
|
// Load peers from API
|
||||||
|
|
@ -106,12 +129,59 @@ export class ChatService extends BaseService {
|
||||||
}
|
}
|
||||||
// Computed properties
|
// Computed properties
|
||||||
get allPeers() {
|
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() {
|
get totalUnreadCount() {
|
||||||
return computed(() => {
|
return computed(() => {
|
||||||
|
if (!this.notificationStore) return 0 // Not initialized yet
|
||||||
return Array.from(this.peers.value.values())
|
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() {
|
get isReady() {
|
||||||
|
|
@ -123,7 +193,13 @@ export class ChatService extends BaseService {
|
||||||
}
|
}
|
||||||
// Get peer by pubkey
|
// Get peer by pubkey
|
||||||
getPeer(pubkey: string): ChatPeer | undefined {
|
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
|
// Add or update a peer
|
||||||
addPeer(pubkey: string, name?: string): ChatPeer {
|
addPeer(pubkey: string, name?: string): ChatPeer {
|
||||||
|
|
@ -133,7 +209,9 @@ export class ChatService extends BaseService {
|
||||||
pubkey,
|
pubkey,
|
||||||
name: name || `User ${pubkey.slice(0, 8)}`,
|
name: name || `User ${pubkey.slice(0, 8)}`,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
lastSeen: Date.now()
|
lastSent: 0,
|
||||||
|
lastReceived: 0,
|
||||||
|
lastChecked: 0
|
||||||
}
|
}
|
||||||
this.peers.value.set(pubkey, peer)
|
this.peers.value.set(pubkey, peer)
|
||||||
this.savePeersToStorage()
|
this.savePeersToStorage()
|
||||||
|
|
@ -153,7 +231,7 @@ export class ChatService extends BaseService {
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
if (!peerMessages.some(m => m.id === message.id)) {
|
if (!peerMessages.some(m => m.id === message.id)) {
|
||||||
peerMessages.push(message)
|
peerMessages.push(message)
|
||||||
// Sort by timestamp
|
// Sort by timestamp (ascending - chronological order within conversation)
|
||||||
peerMessages.sort((a, b) => a.created_at - b.created_at)
|
peerMessages.sort((a, b) => a.created_at - b.created_at)
|
||||||
// Limit message count
|
// Limit message count
|
||||||
if (peerMessages.length > this.config.maxMessages) {
|
if (peerMessages.length > this.config.maxMessages) {
|
||||||
|
|
@ -162,42 +240,95 @@ export class ChatService extends BaseService {
|
||||||
// Update peer info
|
// Update peer info
|
||||||
const peer = this.addPeer(peerPubkey)
|
const peer = this.addPeer(peerPubkey)
|
||||||
peer.lastMessage = message
|
peer.lastMessage = message
|
||||||
peer.lastSeen = Date.now()
|
|
||||||
// Update unread count if message is not sent by us
|
// Update lastSent or lastReceived based on message direction (Coracle pattern)
|
||||||
if (!message.sent) {
|
if (message.sent) {
|
||||||
this.updateUnreadCount(peerPubkey, message)
|
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
|
// Emit events
|
||||||
const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received'
|
const eventType = message.sent ? 'chat:message-sent' : 'chat:message-received'
|
||||||
eventBus.emit(eventType, { message, peerPubkey }, 'chat-service')
|
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
|
// Mark messages as read for a peer
|
||||||
markAsRead(peerPubkey: string): void {
|
markAsRead(peerPubkey: string, timestamp?: number): void {
|
||||||
const peer = this.peers.value.get(peerPubkey)
|
const peer = this.peers.value.get(peerPubkey)
|
||||||
if (peer && peer.unreadCount > 0) {
|
if (peer) {
|
||||||
peer.unreadCount = 0
|
const ts = timestamp || Math.floor(Date.now() / 1000)
|
||||||
// Save unread state
|
|
||||||
const unreadData: UnreadMessageData = {
|
// Update lastChecked timestamp (Coracle pattern)
|
||||||
lastReadTimestamp: Date.now(),
|
const oldChecked = peer.lastChecked
|
||||||
unreadCount: 0,
|
peer.lastChecked = Math.max(peer.lastChecked, ts)
|
||||||
processedMessageIds: new Set()
|
|
||||||
|
// 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
|
// Refresh peers from API
|
||||||
async refreshPeers(): Promise<void> {
|
async refreshPeers(): Promise<void> {
|
||||||
// Check if we should trigger full initialization
|
// Check if we should trigger full initialization
|
||||||
// Removed dual auth import
|
// Removed dual auth import
|
||||||
const hasAuth = this.authService?.user?.value?.pubkey
|
const hasAuth = this.authService?.user?.value?.pubkey
|
||||||
if (!this.isFullyInitialized && hasAuth) {
|
if (!this.isFullyInitialized && hasAuth) {
|
||||||
console.log('💬 Refresh peers triggered full initialization')
|
|
||||||
await this.completeInitialization()
|
await this.completeInitialization()
|
||||||
}
|
}
|
||||||
return this.loadPeersFromAPI()
|
return this.loadPeersFromAPI()
|
||||||
|
|
@ -252,8 +383,7 @@ export class ChatService extends BaseService {
|
||||||
// Add to local messages immediately
|
// Add to local messages immediately
|
||||||
this.addMessage(peerPubkey, message)
|
this.addMessage(peerPubkey, message)
|
||||||
// Publish to Nostr relays
|
// Publish to Nostr relays
|
||||||
const result = await relayHub.publishEvent(signedEvent)
|
await relayHub.publishEvent(signedEvent)
|
||||||
console.log('Message published to relays:', { success: result.success, total: result.total })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send message:', error)
|
console.error('Failed to send message:', error)
|
||||||
throw error
|
throw error
|
||||||
|
|
@ -272,42 +402,6 @@ export class ChatService extends BaseService {
|
||||||
return bytes
|
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
|
// Load peers from API
|
||||||
async loadPeersFromAPI(): Promise<void> {
|
async loadPeersFromAPI(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -316,9 +410,15 @@ export class ChatService extends BaseService {
|
||||||
console.warn('💬 No authentication token found for loading peers from API')
|
console.warn('💬 No authentication token found for loading peers from API')
|
||||||
throw new Error('No authentication token found')
|
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'
|
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: {
|
headers: {
|
||||||
'Authorization': `Bearer ${authToken}`,
|
'Authorization': `Bearer ${authToken}`,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
|
|
@ -330,7 +430,6 @@ export class ChatService extends BaseService {
|
||||||
throw new Error(`Failed to load peers: ${response.status} - ${errorText}`)
|
throw new Error(`Failed to load peers: ${response.status} - ${errorText}`)
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('💬 API returned', data?.length || 0, 'peers')
|
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
console.warn('💬 Invalid API response format - expected array, got:', typeof data)
|
console.warn('💬 Invalid API response format - expected array, got:', typeof data)
|
||||||
return
|
return
|
||||||
|
|
@ -341,17 +440,35 @@ export class ChatService extends BaseService {
|
||||||
console.warn('💬 Skipping peer without pubkey:', peer)
|
console.warn('💬 Skipping peer without pubkey:', peer)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chatPeer: ChatPeer = {
|
|
||||||
pubkey: peer.pubkey,
|
// CRITICAL: Skip current user - you can't chat with yourself!
|
||||||
name: peer.username || `User ${peer.pubkey.slice(0, 8)}`,
|
if (currentUserPubkey && peer.pubkey === currentUserPubkey) {
|
||||||
unreadCount: 0,
|
return
|
||||||
lastSeen: Date.now()
|
}
|
||||||
|
|
||||||
|
// 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
|
// Save to storage
|
||||||
this.savePeersToStorage()
|
this.savePeersToStorage()
|
||||||
console.log(`✅ Loaded ${data.length} peers from API, total peers now: ${this.peers.value.size}`)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to load peers from API:', error)
|
console.error('❌ Failed to load peers from API:', error)
|
||||||
// Don't re-throw - peers from storage are still available
|
// Don't re-throw - peers from storage are still available
|
||||||
|
|
@ -366,9 +483,15 @@ export class ChatService extends BaseService {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[]
|
const peersArray = this.storageService.getUserData('chat-peers', []) as ChatPeer[]
|
||||||
console.log('💬 Loading', peersArray.length, 'peers from storage')
|
|
||||||
peersArray.forEach(peer => {
|
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) {
|
} catch (error) {
|
||||||
console.warn('💬 Failed to load peers from storage:', 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())
|
const peerPubkeys = Array.from(this.peers.value.keys())
|
||||||
if (peerPubkeys.length === 0) {
|
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
|
// Query historical messages (kind 4) to/from known peers
|
||||||
// We need separate queries for sent vs received messages due to different tagging
|
// We need separate queries for sent vs received messages due to different tagging
|
||||||
const receivedEvents = await this.relayHub.queryEvents([
|
const receivedEvents = await this.relayHub.queryEvents([
|
||||||
|
|
@ -412,7 +533,7 @@ export class ChatService extends BaseService {
|
||||||
])
|
])
|
||||||
const sentEvents = await this.relayHub.queryEvents([
|
const sentEvents = await this.relayHub.queryEvents([
|
||||||
{
|
{
|
||||||
kinds: [4],
|
kinds: [4],
|
||||||
authors: [userPubkey], // Messages from us
|
authors: [userPubkey], // Messages from us
|
||||||
'#p': peerPubkeys, // Messages tagged to peers
|
'#p': peerPubkeys, // Messages tagged to peers
|
||||||
limit: 100
|
limit: 100
|
||||||
|
|
@ -420,15 +541,36 @@ export class ChatService extends BaseService {
|
||||||
])
|
])
|
||||||
const events = [...receivedEvents, ...sentEvents]
|
const events = [...receivedEvents, ...sentEvents]
|
||||||
.sort((a, b) => a.created_at - b.created_at) // Sort by timestamp
|
.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
|
// Process historical messages
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
try {
|
try {
|
||||||
const isFromUs = event.pubkey === userPubkey
|
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.tags.find((tag: string[]) => tag[0] === 'p')?.[1] // Get recipient from tag
|
||||||
: event.pubkey // Sender is the peer
|
: event.pubkey // Sender is the peer
|
||||||
if (!peerPubkey || peerPubkey === userPubkey) continue
|
if (!peerPubkey || peerPubkey === userPubkey) continue
|
||||||
|
|
||||||
// Decrypt the message
|
// Decrypt the message
|
||||||
const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content)
|
const decryptedContent = await nip04.decrypt(userPrivkey, peerPubkey, event.content)
|
||||||
// Create a chat message
|
// Create a chat message
|
||||||
|
|
@ -439,13 +581,13 @@ export class ChatService extends BaseService {
|
||||||
sent: isFromUs,
|
sent: isFromUs,
|
||||||
pubkey: event.pubkey
|
pubkey: event.pubkey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the message (will avoid duplicates)
|
// Add the message (will avoid duplicates)
|
||||||
this.addMessage(peerPubkey, message)
|
this.addMessage(peerPubkey, message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to decrypt historical message:', error)
|
console.error('Failed to decrypt historical message:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('Message history loaded successfully')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load message history:', 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...')
|
console.warn('💬 RelayHub not connected, waiting for connection...')
|
||||||
// Listen for connection event
|
// Listen for connection event
|
||||||
this.relayHub.on('connected', () => {
|
this.relayHub.on('connected', () => {
|
||||||
console.log('💬 RelayHub connected, setting up message subscription...')
|
|
||||||
this.setupMessageSubscription()
|
this.setupMessageSubscription()
|
||||||
})
|
})
|
||||||
// Also retry after timeout in case event is missed
|
// Also retry after timeout in case event is missed
|
||||||
|
|
@ -502,10 +643,8 @@ export class ChatService extends BaseService {
|
||||||
await this.processIncomingMessage(event)
|
await this.processIncomingMessage(event)
|
||||||
},
|
},
|
||||||
onEose: () => {
|
onEose: () => {
|
||||||
console.log('💬 Chat message subscription EOSE received')
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
console.log('💬 Chat message subscription set up successfully for pubkey:', userPubkey.substring(0, 10) + '...')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💬 Failed to setup message subscription:', error)
|
console.error('💬 Failed to setup message subscription:', error)
|
||||||
// Retry after delay
|
// Retry after delay
|
||||||
|
|
@ -590,7 +729,6 @@ export class ChatService extends BaseService {
|
||||||
// Forward to market handler
|
// Forward to market handler
|
||||||
if (this.marketMessageHandler) {
|
if (this.marketMessageHandler) {
|
||||||
await this.marketMessageHandler(event)
|
await this.marketMessageHandler(event)
|
||||||
console.log('💬 Market message forwarded to market handler and will also be added to chat')
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('Market message handler not available, message will be treated as chat')
|
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)
|
this.addPeer(senderPubkey)
|
||||||
// Add the message
|
// Add the message
|
||||||
this.addMessage(senderPubkey, message)
|
this.addMessage(senderPubkey, message)
|
||||||
console.log('Received encrypted chat message from:', senderPubkey.slice(0, 8))
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to process incoming message:', 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
|
name?: string
|
||||||
lastMessage?: ChatMessage
|
lastMessage?: ChatMessage
|
||||||
unreadCount: number
|
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 {
|
export interface NostrRelayConfig {
|
||||||
|
|
@ -22,18 +24,17 @@ export interface NostrRelayConfig {
|
||||||
write?: boolean
|
write?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnreadMessageData {
|
export interface ChatNotificationConfig {
|
||||||
lastReadTimestamp: number
|
enabled: boolean
|
||||||
unreadCount: number
|
soundEnabled: boolean
|
||||||
processedMessageIds: Set<string>
|
wildcardSupport: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatConfig {
|
export interface ChatConfig {
|
||||||
maxMessages: number
|
maxMessages: number
|
||||||
autoScroll: boolean
|
autoScroll: boolean
|
||||||
showTimestamps: boolean
|
showTimestamps: boolean
|
||||||
notificationsEnabled: boolean
|
notifications?: ChatNotificationConfig
|
||||||
soundEnabled: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events emitted by chat module
|
// Events emitted by chat module
|
||||||
|
|
|
||||||
|
|
@ -127,11 +127,17 @@
|
||||||
<FormField name="images">
|
<FormField name="images">
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Product Images</FormLabel>
|
<FormLabel>Product Images</FormLabel>
|
||||||
<FormDescription>Add images to showcase your product</FormDescription>
|
<FormDescription>Add up to 5 images to showcase your product. The first image will be the primary display image.</FormDescription>
|
||||||
<div class="text-center py-8 border-2 border-dashed rounded-lg">
|
<ImageUpload
|
||||||
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
|
v-model="uploadedImages"
|
||||||
<p class="text-sm text-muted-foreground">Image upload coming soon</p>
|
:multiple="true"
|
||||||
</div>
|
:max-files="5"
|
||||||
|
:max-size-mb="10"
|
||||||
|
:show-primary-button="true"
|
||||||
|
:disabled="isCreating"
|
||||||
|
:allow-camera="true"
|
||||||
|
placeholder="Add product photos"
|
||||||
|
/>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
|
@ -218,12 +224,13 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
import { Package } from 'lucide-vue-next'
|
|
||||||
import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
|
import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
import { auth } from '@/composables/useAuthService'
|
import { auth } from '@/composables/useAuthService'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
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
|
// Props and emits
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -242,11 +249,13 @@ const emit = defineEmits<{
|
||||||
// Services
|
// Services
|
||||||
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
|
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
|
||||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
|
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
|
||||||
|
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const isCreating = ref(false)
|
const isCreating = ref(false)
|
||||||
const createError = ref<string | null>(null)
|
const createError = ref<string | null>(null)
|
||||||
|
const uploadedImages = ref<any[]>([]) // Track uploaded images with their metadata
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const isEditMode = computed(() => !!props.product?.id)
|
const isEditMode = computed(() => !!props.product?.id)
|
||||||
|
|
@ -311,18 +320,31 @@ const updateProduct = async (formData: any) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
price,
|
price,
|
||||||
quantity,
|
quantity,
|
||||||
categories,
|
categories,
|
||||||
images,
|
active,
|
||||||
active,
|
use_autoreply,
|
||||||
use_autoreply,
|
autoreply_message
|
||||||
autoreply_message
|
|
||||||
} = formData
|
} = 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
|
isCreating.value = true
|
||||||
createError.value = null
|
createError.value = null
|
||||||
|
|
||||||
|
|
@ -382,18 +404,31 @@ const createProduct = async (formData: any) => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
price,
|
price,
|
||||||
quantity,
|
quantity,
|
||||||
categories,
|
categories,
|
||||||
images,
|
active,
|
||||||
active,
|
use_autoreply,
|
||||||
use_autoreply,
|
autoreply_message
|
||||||
autoreply_message
|
|
||||||
} = formData
|
} = 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
|
isCreating.value = true
|
||||||
createError.value = null
|
createError.value = null
|
||||||
|
|
||||||
|
|
@ -420,6 +455,17 @@ const createProduct = async (formData: any) => {
|
||||||
throw new Error('No wallet admin key available')
|
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(
|
const newProduct = await nostrmarketAPI.createProduct(
|
||||||
adminKey,
|
adminKey,
|
||||||
productData
|
productData
|
||||||
|
|
@ -470,6 +516,34 @@ watch(() => props.isOpen, async (isOpen) => {
|
||||||
// Reset form with appropriate initial values
|
// Reset form with appropriate initial values
|
||||||
resetForm({ values: initialValues })
|
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
|
// Wait for reactivity
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,69 +215,107 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useMarketStore } from '../stores/market'
|
|
||||||
import { useAuth } from '@/composables/useAuthService'
|
import { useAuth } from '@/composables/useAuthService'
|
||||||
import { useMarket } from '../composables/useMarket'
|
import { useMarket } from '../composables/useMarket'
|
||||||
// import { useOrderEvents } from '@/composables/useOrderEvents' // TODO: Move to market module
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Store,
|
Store,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Clock
|
Clock
|
||||||
} from 'lucide-vue-next'
|
} 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 router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
|
||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const { isConnected } = useMarket()
|
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
|
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
|
// Computed properties
|
||||||
const orderStats = computed(() => {
|
const orderStats = computed(() => {
|
||||||
const orders = Object.values(marketStore.orders)
|
const allOrders = orders.value
|
||||||
const now = Date.now() / 1000
|
const now = Date.now() / 1000
|
||||||
const sevenDaysAgo = now - (7 * 24 * 60 * 60)
|
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 {
|
return {
|
||||||
total: orders.length,
|
total: allOrders.length,
|
||||||
pending: orders.filter(o => o.status === 'pending').length,
|
pending: unpaidOrders.length,
|
||||||
paid: orders.filter(o => o.status === 'paid').length,
|
paid: paidOrders.length,
|
||||||
pendingPayments: orders.filter(o => o.paymentStatus === 'pending').length,
|
shipped: shippedOrders.length,
|
||||||
pendingAmount: orders
|
pendingPayments: unpaidOrders.length,
|
||||||
.filter(o => o.paymentStatus === 'pending')
|
pendingAmount: pendingAmount,
|
||||||
.reduce((sum, o) => sum + o.total, 0),
|
recentSales: recentSales.length,
|
||||||
recentSales: orders.filter(o =>
|
active: allOrders.filter(o => o.paid && !o.shipped).length,
|
||||||
o.status === 'paid' && o.createdAt > sevenDaysAgo
|
connected: isConnected.value
|
||||||
).length,
|
|
||||||
active: orders.filter(o =>
|
|
||||||
['pending', 'paid', 'processing'].includes(o.status)
|
|
||||||
).length,
|
|
||||||
connected: false
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const recentActivity = computed(() => {
|
const recentActivity = computed(() => {
|
||||||
const orders = Object.values(marketStore.orders)
|
const allOrders = orders.value
|
||||||
const now = Date.now() / 1000
|
const now = Date.now() / 1000
|
||||||
const recentOrders = orders
|
const oneDayAgo = now - (24 * 60 * 60)
|
||||||
.filter(o => o.updatedAt > now - (24 * 60 * 60)) // Last 24 hours
|
|
||||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
// 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)
|
.slice(0, 5)
|
||||||
|
|
||||||
return recentOrders.map(order => ({
|
return recentOrders.map(order => {
|
||||||
id: order.id,
|
let status = 'pending'
|
||||||
type: 'order',
|
if (order.shipped) status = 'shipped'
|
||||||
title: `Order ${order.id.slice(-8)} - ${order.status}`,
|
else if (order.paid) status = 'paid'
|
||||||
status: order.status,
|
|
||||||
timestamp: order.updatedAt
|
return {
|
||||||
}))
|
id: order.id,
|
||||||
|
type: 'order',
|
||||||
|
title: `Order ${order.id.slice(-8)}`,
|
||||||
|
status: status,
|
||||||
|
timestamp: order.time || 0
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Methods
|
// Methods
|
||||||
|
|
@ -308,5 +346,10 @@ const navigateToOrders = () => router.push('/market-dashboard?tab=orders')
|
||||||
const navigateToCart = () => router.push('/cart')
|
const navigateToCart = () => router.push('/cart')
|
||||||
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
const navigateToStore = () => router.push('/market-dashboard?tab=store')
|
||||||
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
const navigateToProducts = () => router.push('/market-dashboard?tab=store')
|
||||||
|
|
||||||
|
// Load orders when component mounts
|
||||||
|
onMounted(() => {
|
||||||
|
fetchOrders()
|
||||||
|
})
|
||||||
</script>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Products Section -->
|
<!-- Store Tabs -->
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
<h3 class="text-xl font-semibold text-foreground">Products</h3>
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
<Button @click="showCreateProductDialog = true" variant="default" size="sm">
|
<TabsTrigger value="products">Products</TabsTrigger>
|
||||||
<Plus class="w-4 h-4 mr-2" />
|
<TabsTrigger value="orders">Orders</TabsTrigger>
|
||||||
Add Product
|
</TabsList>
|
||||||
</Button>
|
|
||||||
</div>
|
<!-- 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 -->
|
<!-- Loading Products -->
|
||||||
<div v-if="isLoadingProducts" class="flex items-center justify-center py-12">
|
<div v-if="isLoadingProducts" class="flex items-center justify-center py-12">
|
||||||
|
|
@ -276,6 +284,19 @@
|
||||||
<Badge :variant="product.active ? 'default' : 'secondary'">
|
<Badge :variant="product.active ? 'default' : 'secondary'">
|
||||||
{{ product.active ? 'Active' : 'Inactive' }}
|
{{ product.active ? 'Active' : 'Inactive' }}
|
||||||
</Badge>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -292,18 +313,54 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Product Actions -->
|
<!-- Product Actions -->
|
||||||
<div class="flex justify-end pt-2 border-t">
|
<div class="flex justify-between pt-2 border-t">
|
||||||
<Button
|
<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)"
|
@click="editProduct(product)"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Edit
|
<div class="flex items-center">
|
||||||
|
<Edit class="w-4 h-4 mr-1" />
|
||||||
|
Edit
|
||||||
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- Orders Tab -->
|
||||||
|
<TabsContent value="orders" class="mt-6">
|
||||||
|
<MerchantOrders :stall-id="activeStallId || undefined" />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -325,6 +382,16 @@
|
||||||
@created="onProductCreated"
|
@created="onProductCreated"
|
||||||
@updated="onProductUpdated"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -334,15 +401,27 @@ import { useRouter } from 'vue-router'
|
||||||
import { useMarketStore } from '@/modules/market/stores/market'
|
import { useMarketStore } from '@/modules/market/stores/market'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
Store,
|
Store,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Star,
|
Star,
|
||||||
Plus,
|
Plus,
|
||||||
User
|
User,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
Edit,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-vue-next'
|
} 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 type { Product } from '../types/market'
|
||||||
import { mapApiResponseToProduct } from '../types/market'
|
import { mapApiResponseToProduct } from '../types/market'
|
||||||
import { auth } from '@/composables/useAuthService'
|
import { auth } from '@/composables/useAuthService'
|
||||||
|
|
@ -350,7 +429,9 @@ import { useToast } from '@/core/composables/useToast'
|
||||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||||
import CreateStoreDialog from './CreateStoreDialog.vue'
|
import CreateStoreDialog from './CreateStoreDialog.vue'
|
||||||
import CreateProductDialog from './CreateProductDialog.vue'
|
import CreateProductDialog from './CreateProductDialog.vue'
|
||||||
|
import DeleteConfirmDialog from './DeleteConfirmDialog.vue'
|
||||||
import StoreCard from './StoreCard.vue'
|
import StoreCard from './StoreCard.vue'
|
||||||
|
import MerchantOrders from './MerchantOrders.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const marketStore = useMarketStore()
|
const marketStore = useMarketStore()
|
||||||
|
|
@ -376,10 +457,25 @@ const activeStall = computed(() =>
|
||||||
const stallProducts = ref<Product[]>([])
|
const stallProducts = ref<Product[]>([])
|
||||||
const isLoadingProducts = ref(false)
|
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
|
// Dialog state
|
||||||
const showCreateStoreDialog = ref(false)
|
const showCreateStoreDialog = ref(false)
|
||||||
const showCreateProductDialog = ref(false)
|
const showCreateProductDialog = ref(false)
|
||||||
|
const showDeleteConfirmDialog = ref(false)
|
||||||
const editingProduct = ref<Product | null>(null)
|
const editingProduct = ref<Product | null>(null)
|
||||||
|
const productToDelete = ref<Product | null>(null)
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const userHasMerchantProfile = computed(() => {
|
const userHasMerchantProfile = computed(() => {
|
||||||
|
|
@ -390,6 +486,17 @@ const userHasStalls = computed(() => {
|
||||||
return userStalls.value.length > 0
|
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 storeStats = computed(() => {
|
||||||
const currentUserPubkey = auth.currentUser?.value?.pubkey
|
const currentUserPubkey = auth.currentUser?.value?.pubkey
|
||||||
if (!currentUserPubkey) {
|
if (!currentUserPubkey) {
|
||||||
|
|
@ -534,6 +641,9 @@ const loadStallProducts = async () => {
|
||||||
.forEach(product => {
|
.forEach(product => {
|
||||||
marketStore.addProduct(product)
|
marketStore.addProduct(product)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Initialize sync status for loaded products
|
||||||
|
initializeSyncStatus()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load products:', error)
|
console.error('Failed to load products:', error)
|
||||||
stallProducts.value = []
|
stallProducts.value = []
|
||||||
|
|
@ -605,11 +715,248 @@ const editProduct = (product: Product) => {
|
||||||
showCreateProductDialog.value = true
|
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 = () => {
|
const closeProductDialog = () => {
|
||||||
showCreateProductDialog.value = false
|
showCreateProductDialog.value = false
|
||||||
editingProduct.value = null
|
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
|
// Lifecycle
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log('Merchant Store component loaded')
|
console.log('Merchant Store component loaded')
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
||||||
<!-- Product Image -->
|
<!-- Product Image with Cycling and Progressive Loading -->
|
||||||
<div class="relative">
|
<div class="relative group">
|
||||||
<!-- Show actual image if available -->
|
<!-- Show actual image with progressive loading if available -->
|
||||||
<ProgressiveImage
|
<ProgressiveImage
|
||||||
v-if="product.images?.[0]"
|
v-if="currentImage"
|
||||||
:src="product.images[0]"
|
:src="currentImage"
|
||||||
:alt="product.name"
|
:alt="product.name"
|
||||||
container-class="w-full h-48 bg-muted/50"
|
container-class="w-full h-48 bg-muted/50"
|
||||||
image-class="w-full h-48 object-cover"
|
image-class="w-full h-48 object-cover"
|
||||||
|
|
@ -23,7 +23,43 @@
|
||||||
<span class="text-xs text-muted-foreground">No image available</span>
|
<span class="text-xs text-muted-foreground">No image available</span>
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Add to Cart Button -->
|
||||||
<Button
|
<Button
|
||||||
@click="addToCart"
|
@click="addToCart"
|
||||||
|
|
@ -33,7 +69,7 @@
|
||||||
>
|
>
|
||||||
<ShoppingCart class="w-4 h-4" />
|
<ShoppingCart class="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Out of Stock Badge -->
|
<!-- Out of Stock Badge -->
|
||||||
<Badge
|
<Badge
|
||||||
v-if="product.quantity < 1"
|
v-if="product.quantity < 1"
|
||||||
|
|
@ -115,12 +151,12 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
||||||
import { ShoppingCart, Package } from 'lucide-vue-next'
|
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import type { Product } from '@/modules/market/stores/market'
|
import type { Product } from '@/modules/market/stores/market'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -136,6 +172,53 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const imageError = ref(false)
|
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 = () => {
|
const addToCart = () => {
|
||||||
emit('add-to-cart', props.product)
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Product Detail Dialog - Now managed internally -->
|
|
||||||
<ProductDetailDialog
|
|
||||||
v-if="selectedProduct"
|
|
||||||
:product="selectedProduct"
|
|
||||||
:isOpen="showProductDetail"
|
|
||||||
@close="closeProductDetail"
|
|
||||||
@add-to-cart="handleDialogAddToCart"
|
|
||||||
/>
|
|
||||||
</LoadingErrorState>
|
</LoadingErrorState>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Package as EmptyIcon } from 'lucide-vue-next'
|
||||||
import ProductCard from './ProductCard.vue'
|
import ProductCard from './ProductCard.vue'
|
||||||
import ProductDetailDialog from './ProductDetailDialog.vue'
|
|
||||||
import LoadingErrorState from './LoadingErrorState.vue'
|
import LoadingErrorState from './LoadingErrorState.vue'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
|
|
||||||
|
|
@ -99,29 +91,15 @@ const gridClasses = computed(() => {
|
||||||
return classes.join(' ')
|
return classes.join(' ')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Internal state for product detail dialog
|
const router = useRouter()
|
||||||
const showProductDetail = ref(false)
|
|
||||||
const selectedProduct = ref<Product | null>(null)
|
|
||||||
|
|
||||||
// Handle view details internally
|
// Handle view details by navigating to product page
|
||||||
const handleViewDetails = (product: Product) => {
|
const handleViewDetails = (product: Product) => {
|
||||||
selectedProduct.value = product
|
router.push(`/market/product/${product.id}`)
|
||||||
showProductDetail.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeProductDetail = () => {
|
|
||||||
showProductDetail.value = false
|
|
||||||
selectedProduct.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle add to cart from product card (quick add, quantity 1)
|
// Handle add to cart from product card (quick add, quantity 1)
|
||||||
const handleAddToCart = (product: Product) => {
|
const handleAddToCart = (product: Product) => {
|
||||||
emit('add-to-cart', product, 1)
|
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>
|
</script>
|
||||||
|
|
@ -297,6 +297,14 @@ export function useMarket() {
|
||||||
.map((tag: any) => tag[1])
|
.map((tag: any) => tag[1])
|
||||||
.filter((cat: string) => cat && cat.trim())
|
.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
|
// Look up the stall name from the stalls array
|
||||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||||
|
|
@ -310,7 +318,7 @@ export function useMarket() {
|
||||||
description: productData.description || '',
|
description: productData.description || '',
|
||||||
price: productData.price || 0,
|
price: productData.price || 0,
|
||||||
currency: productData.currency || 'sats',
|
currency: productData.currency || 'sats',
|
||||||
quantity: productData.quantity || 1,
|
quantity: productData.quantity ?? 1, // Use nullish coalescing to preserve 0
|
||||||
images: productData.images || [],
|
images: productData.images || [],
|
||||||
categories: categories,
|
categories: categories,
|
||||||
createdAt: latestEvent.created_at,
|
createdAt: latestEvent.created_at,
|
||||||
|
|
@ -489,6 +497,15 @@ export function useMarket() {
|
||||||
.map((tag: any) => tag[1])
|
.map((tag: any) => tag[1])
|
||||||
.filter((cat: string) => cat && cat.trim())
|
.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
|
// Look up the stall name from the stalls array
|
||||||
const stall = marketStore.stalls.find(s => s.id === stallId)
|
const stall = marketStore.stalls.find(s => s.id === stallId)
|
||||||
const stallName = stall?.name || 'Unknown Stall'
|
const stallName = stall?.name || 'Unknown Stall'
|
||||||
|
|
@ -502,7 +519,7 @@ export function useMarket() {
|
||||||
description: productData.description || '',
|
description: productData.description || '',
|
||||||
price: productData.price || 0,
|
price: productData.price || 0,
|
||||||
currency: productData.currency || 'sats',
|
currency: productData.currency || 'sats',
|
||||||
quantity: productData.quantity || 1,
|
quantity: productData.quantity ?? 1, // Use nullish coalescing to preserve 0
|
||||||
images: productData.images || [],
|
images: productData.images || [],
|
||||||
categories: categories,
|
categories: categories,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
|
|
@ -516,17 +533,7 @@ export function useMarket() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish a product
|
// Publishing methods removed - now handled by LNbits API endpoints
|
||||||
const publishProduct = async (_productData: any) => {
|
|
||||||
// Implementation would depend on your event creation logic
|
|
||||||
// TODO: Implement product publishing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish a stall
|
|
||||||
const publishStall = async (_stallData: any) => {
|
|
||||||
// Implementation would depend on your event creation logic
|
|
||||||
// TODO: Implement stall publishing
|
|
||||||
}
|
|
||||||
|
|
||||||
// Connect to market
|
// Connect to market
|
||||||
const connectToMarket = async () => {
|
const connectToMarket = async () => {
|
||||||
|
|
@ -617,8 +624,6 @@ export function useMarket() {
|
||||||
connectToMarket,
|
connectToMarket,
|
||||||
disconnectFromMarket,
|
disconnectFromMarket,
|
||||||
processPendingProducts,
|
processPendingProducts,
|
||||||
publishProduct,
|
|
||||||
publishStall,
|
|
||||||
subscribeToMarketUpdates,
|
subscribeToMarketUpdates,
|
||||||
subscribeToOrderUpdates
|
subscribeToOrderUpdates
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,15 @@ export const marketModule: ModulePlugin = {
|
||||||
title: 'Stall',
|
title: 'Stall',
|
||||||
requiresAuth: false
|
requiresAuth: false
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/market/product/:productId',
|
||||||
|
name: 'product-detail',
|
||||||
|
component: () => import('./views/ProductDetailPage.vue'),
|
||||||
|
meta: {
|
||||||
|
title: 'Product Details',
|
||||||
|
requiresAuth: false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
] as RouteRecordRaw[],
|
] 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 {
|
export class NostrmarketAPI extends BaseService {
|
||||||
// Service metadata
|
// Service metadata
|
||||||
protected readonly metadata = {
|
protected readonly metadata = {
|
||||||
|
|
@ -368,6 +424,20 @@ export class NostrmarketAPI extends BaseService {
|
||||||
walletAdminkey: string,
|
walletAdminkey: string,
|
||||||
productData: CreateProductRequest
|
productData: CreateProductRequest
|
||||||
): Promise<ProductApiResponse> {
|
): 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>(
|
const product = await this.request<ProductApiResponse>(
|
||||||
'/api/v1/product',
|
'/api/v1/product',
|
||||||
walletAdminkey,
|
walletAdminkey,
|
||||||
|
|
@ -377,10 +447,12 @@ export class NostrmarketAPI extends BaseService {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
this.debug('Created product:', {
|
this.debug('Created product response:', {
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
productName: product.name,
|
productName: product.name,
|
||||||
stallId: product.stall_id
|
stallId: product.stall_id,
|
||||||
|
returnedCategories: product.categories,
|
||||||
|
returnedCategoriesLength: product.categories?.length
|
||||||
})
|
})
|
||||||
|
|
||||||
return product
|
return product
|
||||||
|
|
@ -446,4 +518,153 @@ export class NostrmarketAPI extends BaseService {
|
||||||
|
|
||||||
this.debug('Deleted product:', { productId })
|
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 { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||||
import { BaseService } from '@/core/base/BaseService'
|
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 {
|
export interface NostrmarketStall {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -27,6 +27,9 @@ export interface NostrmarketProduct {
|
||||||
currency: string
|
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 {
|
export interface NostrmarketOrder {
|
||||||
id: string
|
id: string
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|
@ -152,90 +155,8 @@ export class NostrmarketService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Removed publishStall() and publishProduct() methods
|
||||||
* Publish a stall event (kind 30017) to Nostr
|
// Stall and product publishing is now handled by LNbits API endpoints
|
||||||
*/
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||||
|
|
@ -471,38 +392,6 @@ export class NostrmarketService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Removed publishMerchantCatalog() method
|
||||||
* Publish all stalls and products for a merchant
|
// Publishing is now handled by LNbits API endpoints
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -470,51 +470,8 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// nostrmarket integration methods
|
// Removed publishToNostrmarket() method
|
||||||
const publishToNostrmarket = async () => {
|
// Publishing is now handled automatically by LNbits API endpoints
|
||||||
try {
|
|
||||||
console.log('Publishing merchant catalog to nostrmarket...')
|
|
||||||
|
|
||||||
// Get all stalls and products
|
|
||||||
const allStalls = Object.values(stalls.value)
|
|
||||||
const allProducts = Object.values(products.value)
|
|
||||||
|
|
||||||
if (allStalls.length === 0) {
|
|
||||||
console.warn('No stalls to publish to nostrmarket')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allProducts.length === 0) {
|
|
||||||
console.warn('No products to publish to nostrmarket')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to nostrmarket
|
|
||||||
const result = await nostrmarketService.publishMerchantCatalog(allStalls, allProducts)
|
|
||||||
|
|
||||||
console.log('Successfully published to nostrmarket:', result)
|
|
||||||
|
|
||||||
// Update stalls and products with event IDs
|
|
||||||
for (const [stallId, eventId] of Object.entries(result.stalls)) {
|
|
||||||
const stall = stalls.value.find(s => s.id === stallId)
|
|
||||||
if (stall) {
|
|
||||||
stall.nostrEventId = eventId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [productId, eventId] of Object.entries(result.products)) {
|
|
||||||
const product = products.value.find(p => p.id === productId)
|
|
||||||
if (product) {
|
|
||||||
product.nostrEventId = eventId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to publish to nostrmarket:', error)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invoice management methods
|
// Invoice management methods
|
||||||
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
const createLightningInvoice = async (orderId: string, adminKey: string): Promise<LightningInvoice | null> => {
|
||||||
|
|
@ -916,6 +873,5 @@ export const useMarketStore = defineStore('market', () => {
|
||||||
saveOrdersToStorage,
|
saveOrdersToStorage,
|
||||||
loadOrdersFromStorage,
|
loadOrdersFromStorage,
|
||||||
clearOrdersForUserChange,
|
clearOrdersForUserChange,
|
||||||
publishToNostrmarket
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -290,7 +290,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
CheckCircle
|
CheckCircle
|
||||||
|
|
|
||||||
|
|
@ -238,7 +238,6 @@ const addToCart = (product: Product, quantity?: number) => {
|
||||||
marketStore.addToStallCart(product, quantity || 1)
|
marketStore.addToStallCart(product, quantity || 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const viewStall = (stallId: string) => {
|
const viewStall = (stallId: string) => {
|
||||||
// Navigate to the stall view page
|
// Navigate to the stall view page
|
||||||
router.push(`/market/stall/${stallId}`)
|
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