feat: enhance product management with new dialog and image handling features
- Introduced ProductDetailDialog component for displaying detailed product information, including images, price, and availability. - Implemented image cycling functionality in ProductCard for better user experience when viewing multiple product images. - Enhanced CreateProductDialog to support image uploads with improved validation and navigation protection during form editing. - Added logic to manage uploaded images and ensure proper handling of existing product images. - Updated MarketPage to integrate the new ProductDetailDialog, allowing users to view product details seamlessly. These changes significantly improve the product management experience, enhancing both the display and interaction with product images.
This commit is contained in:
parent
f7405bc26e
commit
bff158cb74
5 changed files with 561 additions and 93 deletions
355
src/components/ui/ProgressiveImageGallery.vue
Normal file
355
src/components/ui/ProgressiveImageGallery.vue
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
<template>
|
||||
<div class="progressive-image-gallery">
|
||||
<!-- Primary image display with progressive loading -->
|
||||
<div v-if="currentImage" class="primary-image relative">
|
||||
<ProgressiveImage
|
||||
:src="currentImage"
|
||||
: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="showLightbox && openLightbox()"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<Badge
|
||||
v-if="showBadge && images.length > 1"
|
||||
class="absolute top-2 right-2"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ currentImageIndex + 1 }} of {{ images.length }}
|
||||
</Badge>
|
||||
</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-list flex gap-2 mt-3 overflow-x-auto"
|
||||
>
|
||||
<button
|
||||
v-for="(image, 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
|
||||
}"
|
||||
>
|
||||
<ProgressiveImage
|
||||
:src="image"
|
||||
: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 modal -->
|
||||
<Dialog v-if="!isEmbedded" v-model:open="lightboxOpen">
|
||||
<DialogContent class="max-w-4xl p-0">
|
||||
<div class="relative">
|
||||
<ProgressiveImage
|
||||
v-if="lightboxImage"
|
||||
:src="lightboxImage"
|
||||
:alt="alt || 'Full size image'"
|
||||
container-class="w-full"
|
||||
image-class="w-full h-auto max-h-[90vh] object-contain"
|
||||
:blur-radius="12"
|
||||
:transition-duration="500"
|
||||
:show-loading-indicator="true"
|
||||
/>
|
||||
<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="previousLightboxImage"
|
||||
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="nextLightboxImage"
|
||||
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>
|
||||
|
||||
<!-- Image counter -->
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded px-2 py-1 text-sm">
|
||||
{{ lightboxImageIndex + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Embedded lightbox (when used inside another dialog) - using Teleport to escape parent containers -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isEmbedded && lightboxOpen"
|
||||
class="fixed inset-0 z-[9999] 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">
|
||||
<ProgressiveImage
|
||||
v-if="lightboxImage"
|
||||
:src="lightboxImage"
|
||||
:alt="alt || 'Full size image'"
|
||||
container-class="max-w-full max-h-[90vh]"
|
||||
image-class="max-w-full max-h-[90vh] object-contain rounded-lg"
|
||||
:blur-radius="12"
|
||||
:transition-duration="500"
|
||||
:show-loading-indicator="true"
|
||||
/>
|
||||
<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="previousLightboxImage"
|
||||
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="nextLightboxImage"
|
||||
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>
|
||||
|
||||
<!-- Image counter -->
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded px-2 py-1 text-sm">
|
||||
{{ lightboxImageIndex + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { X, ChevronLeft, ChevronRight, Package } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from '@/components/ui/dialog'
|
||||
import ProgressiveImage from './ProgressiveImage.vue'
|
||||
|
||||
interface Props {
|
||||
images: string[]
|
||||
alt?: string
|
||||
containerClass?: string
|
||||
imageClass?: string
|
||||
blurRadius?: number
|
||||
transitionDuration?: number
|
||||
loading?: 'lazy' | 'eager'
|
||||
showLoadingIndicator?: boolean
|
||||
showThumbnails?: boolean
|
||||
showLightbox?: boolean
|
||||
showBadge?: boolean
|
||||
isEmbedded?: boolean
|
||||
initialIndex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alt: '',
|
||||
containerClass: 'w-full h-48 bg-muted/50',
|
||||
imageClass: 'w-full h-48 object-cover',
|
||||
blurRadius: 8,
|
||||
transitionDuration: 400,
|
||||
loading: 'lazy',
|
||||
showLoadingIndicator: true,
|
||||
showThumbnails: true,
|
||||
showLightbox: true,
|
||||
showBadge: true,
|
||||
isEmbedded: false,
|
||||
initialIndex: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: Event]
|
||||
imageChange: [index: number, src: string]
|
||||
}>()
|
||||
|
||||
// Component state
|
||||
const currentImageIndex = ref(props.initialIndex)
|
||||
const lightboxOpen = ref(false)
|
||||
const lightboxImageIndex = ref(0)
|
||||
|
||||
// Computed properties
|
||||
const filteredImages = computed(() => {
|
||||
return props.images.filter(img => img && img.trim() !== '')
|
||||
})
|
||||
|
||||
const currentImage = computed(() => {
|
||||
if (filteredImages.value.length === 0) return null
|
||||
const index = Math.min(currentImageIndex.value, filteredImages.value.length - 1)
|
||||
return filteredImages.value[index]
|
||||
})
|
||||
|
||||
const lightboxImage = computed(() => {
|
||||
if (filteredImages.value.length === 0) return null
|
||||
return filteredImages.value[lightboxImageIndex.value]
|
||||
})
|
||||
|
||||
// Methods
|
||||
const selectImage = (index: number) => {
|
||||
if (index >= 0 && index < filteredImages.value.length) {
|
||||
currentImageIndex.value = index
|
||||
emit('imageChange', index, filteredImages.value[index])
|
||||
}
|
||||
}
|
||||
|
||||
const openLightbox = () => {
|
||||
lightboxImageIndex.value = currentImageIndex.value
|
||||
lightboxOpen.value = true
|
||||
}
|
||||
|
||||
const previousLightboxImage = () => {
|
||||
lightboxImageIndex.value = lightboxImageIndex.value > 0
|
||||
? lightboxImageIndex.value - 1
|
||||
: filteredImages.value.length - 1
|
||||
}
|
||||
|
||||
const nextLightboxImage = () => {
|
||||
lightboxImageIndex.value = lightboxImageIndex.value < filteredImages.value.length - 1
|
||||
? lightboxImageIndex.value + 1
|
||||
: 0
|
||||
}
|
||||
|
||||
const handleImageError = (error: Event) => {
|
||||
emit('error', error)
|
||||
}
|
||||
|
||||
// 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 })
|
||||
|
||||
// Keyboard navigation for lightbox
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!lightboxOpen.value) return
|
||||
|
||||
switch (event.key) {
|
||||
case 'Escape':
|
||||
lightboxOpen.value = false
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault()
|
||||
previousLightboxImage()
|
||||
break
|
||||
case 'ArrowRight':
|
||||
event.preventDefault()
|
||||
nextLightboxImage()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Add keyboard listeners when lightbox opens
|
||||
watch(lightboxOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
// Cleanup keyboard listeners on unmount
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({
|
||||
selectImage,
|
||||
openLightbox,
|
||||
getCurrentIndex: () => currentImageIndex.value,
|
||||
getCurrentImage: () => currentImage.value
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.progressive-image-gallery {
|
||||
@apply w-full;
|
||||
}
|
||||
|
||||
.thumbnail-list {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground)) transparent;
|
||||
}
|
||||
|
||||
.thumbnail-list::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.thumbnail-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.thumbnail-list::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.thumbnail-list::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -127,11 +127,17 @@
|
|||
<FormField name="images">
|
||||
<FormItem>
|
||||
<FormLabel>Product Images</FormLabel>
|
||||
<FormDescription>Add images to showcase your product</FormDescription>
|
||||
<div class="text-center py-8 border-2 border-dashed rounded-lg">
|
||||
<Package class="w-8 h-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p class="text-sm text-muted-foreground">Image upload coming soon</p>
|
||||
</div>
|
||||
<FormDescription>Add up to 5 images to showcase your product. The first image will be the primary display image.</FormDescription>
|
||||
<ImageUpload
|
||||
v-model="uploadedImages"
|
||||
:multiple="true"
|
||||
:max-files="5"
|
||||
:max-size-mb="10"
|
||||
:show-primary-button="true"
|
||||
:disabled="isCreating"
|
||||
:allow-camera="true"
|
||||
placeholder="Add product photos"
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
|
@ -218,12 +224,13 @@ import {
|
|||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Package } from 'lucide-vue-next'
|
||||
import type { NostrmarketAPI, Stall, CreateProductRequest } from '../services/nostrmarketAPI'
|
||||
import type { Product } from '../types/market'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import ImageUpload from '@/modules/base/components/ImageUpload.vue'
|
||||
import type { ImageUploadService } from '@/modules/base/services/ImageUploadService'
|
||||
|
||||
// Props and emits
|
||||
interface Props {
|
||||
|
|
@ -242,11 +249,13 @@ const emit = defineEmits<{
|
|||
// Services
|
||||
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
|
||||
const paymentService = injectService(SERVICE_TOKENS.PAYMENT_SERVICE) as any
|
||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
const toast = useToast()
|
||||
|
||||
// Local state
|
||||
const isCreating = ref(false)
|
||||
const createError = ref<string | null>(null)
|
||||
const uploadedImages = ref<any[]>([]) // Track uploaded images with their metadata
|
||||
|
||||
// Computed properties
|
||||
const isEditMode = computed(() => !!props.product?.id)
|
||||
|
|
@ -311,18 +320,31 @@ const updateProduct = async (formData: any) => {
|
|||
return
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
quantity,
|
||||
categories,
|
||||
images,
|
||||
active,
|
||||
use_autoreply,
|
||||
autoreply_message
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
quantity,
|
||||
categories,
|
||||
active,
|
||||
use_autoreply,
|
||||
autoreply_message
|
||||
} = formData
|
||||
|
||||
// Get uploaded image URLs from the image service
|
||||
const images: string[] = []
|
||||
if (uploadedImages.value && uploadedImages.value.length > 0) {
|
||||
for (const img of uploadedImages.value) {
|
||||
if (img.alias) {
|
||||
// Get the full URL for the image
|
||||
const imageUrl = imageService.getImageUrl(img.alias)
|
||||
if (imageUrl) {
|
||||
images.push(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isCreating.value = true
|
||||
createError.value = null
|
||||
|
||||
|
|
@ -382,18 +404,31 @@ const createProduct = async (formData: any) => {
|
|||
return
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
quantity,
|
||||
categories,
|
||||
images,
|
||||
active,
|
||||
use_autoreply,
|
||||
autoreply_message
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
quantity,
|
||||
categories,
|
||||
active,
|
||||
use_autoreply,
|
||||
autoreply_message
|
||||
} = formData
|
||||
|
||||
// Get uploaded image URLs from the image service
|
||||
const images: string[] = []
|
||||
if (uploadedImages.value && uploadedImages.value.length > 0) {
|
||||
for (const img of uploadedImages.value) {
|
||||
if (img.alias) {
|
||||
// Get the full URL for the image
|
||||
const imageUrl = imageService.getImageUrl(img.alias)
|
||||
if (imageUrl) {
|
||||
images.push(imageUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isCreating.value = true
|
||||
createError.value = null
|
||||
|
||||
|
|
@ -470,6 +505,34 @@ watch(() => props.isOpen, async (isOpen) => {
|
|||
// Reset form with appropriate initial values
|
||||
resetForm({ values: initialValues })
|
||||
|
||||
// Convert existing image URLs to the format expected by ImageUpload component
|
||||
if (props.product?.images && props.product.images.length > 0) {
|
||||
// For existing products, we need to convert URLs back to a format ImageUpload can display
|
||||
uploadedImages.value = props.product.images.map((url, index) => {
|
||||
let alias = url
|
||||
|
||||
// If it's a full pict-rs URL, extract just the file ID
|
||||
if (url.includes('/image/original/')) {
|
||||
const parts = url.split('/image/original/')
|
||||
if (parts.length > 1 && parts[1]) {
|
||||
alias = parts[1]
|
||||
}
|
||||
} else if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
// Keep full URLs as-is
|
||||
alias = url
|
||||
}
|
||||
|
||||
return {
|
||||
alias: alias,
|
||||
delete_token: '',
|
||||
isPrimary: index === 0,
|
||||
details: {}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
uploadedImages.value = []
|
||||
}
|
||||
|
||||
// Wait for reactivity
|
||||
await nextTick()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
<template>
|
||||
<Card class="overflow-hidden hover:shadow-lg transition-shadow duration-200">
|
||||
<!-- Product Image -->
|
||||
<div class="relative">
|
||||
<!-- Show actual image if available -->
|
||||
<!-- Product Image with Cycling and Progressive Loading -->
|
||||
<div class="relative group">
|
||||
<!-- Show actual image with progressive loading if available -->
|
||||
<ProgressiveImage
|
||||
v-if="product.images?.[0]"
|
||||
:src="product.images[0]"
|
||||
v-if="currentImage"
|
||||
:src="currentImage"
|
||||
:alt="product.name"
|
||||
container-class="w-full h-48 bg-muted/50"
|
||||
image-class="w-full h-48 object-cover"
|
||||
|
|
@ -23,7 +23,43 @@
|
|||
<span class="text-xs text-muted-foreground">No image available</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Image Navigation Arrows (show on hover if multiple images) -->
|
||||
<div v-if="hasMultipleImages" class="opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<!-- Previous Image Button -->
|
||||
<Button
|
||||
@click="previousImage"
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
class="absolute left-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white border-0 h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<!-- Next Image Button -->
|
||||
<Button
|
||||
@click="nextImage"
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
class="absolute right-2 top-1/2 transform -translate-y-1/2 bg-black/50 hover:bg-black/70 text-white border-0 h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Image Indicators (dots) -->
|
||||
<div v-if="hasMultipleImages" class="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex gap-1">
|
||||
<div
|
||||
v-for="(_, index) in productImages"
|
||||
:key="index"
|
||||
@click="setCurrentImage(index)"
|
||||
class="w-2 h-2 rounded-full cursor-pointer transition-colors duration-200"
|
||||
:class="currentImageIndex === index ? 'bg-white' : 'bg-white/50 hover:bg-white/70'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Add to Cart Button -->
|
||||
<Button
|
||||
@click="addToCart"
|
||||
|
|
@ -33,7 +69,7 @@
|
|||
>
|
||||
<ShoppingCart class="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
|
||||
<!-- Out of Stock Badge -->
|
||||
<Badge
|
||||
v-if="product.quantity < 1"
|
||||
|
|
@ -115,12 +151,12 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
||||
import { ShoppingCart, Package } from 'lucide-vue-next'
|
||||
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||
import type { Product } from '@/modules/market/stores/market'
|
||||
|
||||
interface Props {
|
||||
|
|
@ -136,6 +172,53 @@ const emit = defineEmits<{
|
|||
}>()
|
||||
|
||||
const imageError = ref(false)
|
||||
const currentImageIndex = ref(0)
|
||||
|
||||
// Computed properties for image cycling
|
||||
const productImages = computed(() => {
|
||||
if (!props.product.images || props.product.images.length === 0) {
|
||||
return []
|
||||
}
|
||||
return props.product.images.filter(img => img && img.trim() !== '')
|
||||
})
|
||||
|
||||
const hasMultipleImages = computed(() => productImages.value.length > 1)
|
||||
|
||||
const currentImage = computed(() => {
|
||||
if (productImages.value.length === 0) {
|
||||
return null
|
||||
}
|
||||
return productImages.value[currentImageIndex.value]
|
||||
})
|
||||
|
||||
// Image cycling methods
|
||||
const nextImage = (event?: Event) => {
|
||||
event?.stopPropagation()
|
||||
if (productImages.value.length > 0) {
|
||||
currentImageIndex.value = (currentImageIndex.value + 1) % productImages.value.length
|
||||
}
|
||||
}
|
||||
|
||||
const previousImage = (event?: Event) => {
|
||||
event?.stopPropagation()
|
||||
if (productImages.value.length > 0) {
|
||||
currentImageIndex.value = currentImageIndex.value === 0
|
||||
? productImages.value.length - 1
|
||||
: currentImageIndex.value - 1
|
||||
}
|
||||
}
|
||||
|
||||
const setCurrentImage = (index: number, event?: Event) => {
|
||||
event?.stopPropagation()
|
||||
if (index >= 0 && index < productImages.value.length) {
|
||||
currentImageIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
// Reset image index when product changes
|
||||
watch(() => props.product.id, () => {
|
||||
currentImageIndex.value = 0
|
||||
})
|
||||
|
||||
const addToCart = () => {
|
||||
emit('add-to-cart', props.product)
|
||||
|
|
|
|||
|
|
@ -8,50 +8,22 @@
|
|||
</DialogClose>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Product Images -->
|
||||
<!-- Product Images with Lightbox -->
|
||||
<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>
|
||||
<ProgressiveImageGallery
|
||||
: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"
|
||||
:is-embedded="true"
|
||||
@error="handleImageError"
|
||||
@image-change="handleImageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Details -->
|
||||
|
|
@ -173,8 +145,8 @@ 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 ProgressiveImageGallery from '@/components/ui/ProgressiveImageGallery.vue'
|
||||
import { ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
|
|
@ -194,7 +166,6 @@ const toast = useToast()
|
|||
|
||||
// Local state
|
||||
const quantity = ref(1)
|
||||
const currentImageIndex = ref(0)
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
// Computed properties
|
||||
|
|
@ -205,13 +176,6 @@ const productImages = computed(() => {
|
|||
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') {
|
||||
|
|
@ -246,22 +210,26 @@ const handleClose = () => {
|
|||
emit('close')
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Reset image error when product changes
|
||||
watch(() => props.product?.id, () => {
|
||||
currentImageIndex.value = 0
|
||||
imageLoadError.value = false
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -238,7 +238,6 @@ const addToCart = (product: Product, quantity?: number) => {
|
|||
marketStore.addToStallCart(product, quantity || 1)
|
||||
}
|
||||
|
||||
|
||||
const viewStall = (stallId: string) => {
|
||||
// Navigate to the stall view page
|
||||
router.push(`/market/stall/${stallId}`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue