feat: add ProductDetailPage introduce ImageViewer and ImageLightbox components for enhanced image display
- ProductDetailPage is being used in lieu of a modal becaues Lightbox image gallery (modal) being embedded in another modal was causing too much buggy behavior - Added ImageViewer component to manage and display product images with features like lightbox, thumbnails, and image cycling controls. - Replaced ProgressiveImageGallery with ImageViewer in ProductDetailDialog and ProductDetailPage for improved user experience and maintainability. - Implemented useImageLightbox composable to handle lightbox functionality, including keyboard navigation and swipe gestures. - Updated routing to include a dedicated product detail page for better navigation and user flow. These changes significantly enhance the image viewing experience in the product detail context, providing a more dynamic and user-friendly interface.
This commit is contained in:
parent
bff158cb74
commit
3aec5bbdb3
8 changed files with 1100 additions and 384 deletions
241
src/components/ui/ImageLightbox.vue
Normal file
241
src/components/ui/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, computed, 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>
|
||||
333
src/components/ui/ImageViewer.vue
Normal file
333
src/components/ui/ImageViewer.vue
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
<template>
|
||||
<div class="image-viewer">
|
||||
<!-- Primary image display with progressive loading -->
|
||||
<div v-if="currentImageSrc" class="primary-image relative">
|
||||
<ProgressiveImage
|
||||
: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>
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
<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>
|
||||
188
src/composables/useImageLightbox.ts
Normal file
188
src/composables/useImageLightbox.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
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,
|
||||
closeOnBackdropClick = 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,7 +10,7 @@
|
|||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Product Images with Lightbox -->
|
||||
<div class="space-y-4">
|
||||
<ProgressiveImageGallery
|
||||
<ImageViewer
|
||||
:images="productImages"
|
||||
:alt="product.name"
|
||||
container-class="aspect-square rounded-lg overflow-hidden bg-gray-100"
|
||||
|
|
@ -20,9 +20,12 @@
|
|||
:show-thumbnails="productImages.length > 1"
|
||||
:show-lightbox="true"
|
||||
:show-badge="productImages.length > 1"
|
||||
:show-cycle-controls="productImages.length > 1"
|
||||
:is-embedded="true"
|
||||
@error="handleImageError"
|
||||
@image-change="handleImageChange"
|
||||
@lightbox-open="handleLightboxOpen"
|
||||
@lightbox-close="handleLightboxClose"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -145,7 +148,7 @@ 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 ProgressiveImageGallery from '@/components/ui/ProgressiveImageGallery.vue'
|
||||
import ImageViewer from '@/components/ui/ImageViewer.vue'
|
||||
import { ShoppingCart, Store, Plus, Minus, X } from 'lucide-vue-next'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
import type { Product } from '../types/market'
|
||||
|
|
@ -220,6 +223,16 @@ const handleImageChange = (index: number, src: string) => {
|
|||
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')
|
||||
}
|
||||
|
||||
// Reset state when dialog opens/closes
|
||||
watch(() => props.isOpen, (newVal) => {
|
||||
if (newVal) {
|
||||
|
|
|
|||
|
|
@ -27,23 +27,15 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product Detail Dialog - Now managed internally -->
|
||||
<ProductDetailDialog
|
||||
v-if="selectedProduct"
|
||||
:product="selectedProduct"
|
||||
:isOpen="showProductDetail"
|
||||
@close="closeProductDetail"
|
||||
@add-to-cart="handleDialogAddToCart"
|
||||
/>
|
||||
</LoadingErrorState>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Package as EmptyIcon } from 'lucide-vue-next'
|
||||
import ProductCard from './ProductCard.vue'
|
||||
import ProductDetailDialog from './ProductDetailDialog.vue'
|
||||
import LoadingErrorState from './LoadingErrorState.vue'
|
||||
import type { Product } from '../types/market'
|
||||
|
||||
|
|
@ -99,29 +91,15 @@ const gridClasses = computed(() => {
|
|||
return classes.join(' ')
|
||||
})
|
||||
|
||||
// Internal state for product detail dialog
|
||||
const showProductDetail = ref(false)
|
||||
const selectedProduct = ref<Product | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// Handle view details internally
|
||||
// Handle view details by navigating to product page
|
||||
const handleViewDetails = (product: Product) => {
|
||||
selectedProduct.value = product
|
||||
showProductDetail.value = true
|
||||
}
|
||||
|
||||
const closeProductDetail = () => {
|
||||
showProductDetail.value = false
|
||||
selectedProduct.value = null
|
||||
router.push(`/market/product/${product.id}`)
|
||||
}
|
||||
|
||||
// Handle add to cart from product card (quick add, quantity 1)
|
||||
const handleAddToCart = (product: Product) => {
|
||||
emit('add-to-cart', product, 1)
|
||||
}
|
||||
|
||||
// Handle add to cart from dialog (with custom quantity)
|
||||
const handleDialogAddToCart = (product: Product, quantity: number) => {
|
||||
emit('add-to-cart', product, quantity)
|
||||
closeProductDetail()
|
||||
}
|
||||
</script>
|
||||
|
|
@ -154,6 +154,15 @@ export const marketModule: ModulePlugin = {
|
|||
title: 'Stall',
|
||||
requiresAuth: false
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/market/product/:productId',
|
||||
name: 'product-detail',
|
||||
component: () => import('./views/ProductDetailPage.vue'),
|
||||
meta: {
|
||||
title: 'Product Details',
|
||||
requiresAuth: false
|
||||
}
|
||||
}
|
||||
] as RouteRecordRaw[],
|
||||
|
||||
|
|
|
|||
309
src/modules/market/views/ProductDetailPage.vue
Normal file
309
src/modules/market/views/ProductDetailPage.vue
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
<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/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')
|
||||
}
|
||||
|
||||
product.value = productData
|
||||
|
||||
// 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