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:
padreug 2025-09-28 12:22:39 +02:00
parent bff158cb74
commit 3aec5bbdb3
8 changed files with 1100 additions and 384 deletions

View file

@ -0,0 +1,241 @@
<template>
<!-- Simple lightbox overlay - always teleported to body -->
<Teleport to="body">
<div
v-if="lightbox.isOpen.value"
class="image-lightbox-overlay fixed inset-0 bg-background/90 backdrop-blur-sm z-[9999] flex items-center justify-center"
@click="lightbox.close"
>
<!-- Lightbox container -->
<div
class="image-lightbox-container relative max-w-[95vw] max-h-[95vh] bg-transparent rounded-lg overflow-hidden"
@click.stop
>
<!-- Main image display -->
<div class="image-lightbox-content relative">
<ProgressiveImage
v-if="lightbox.currentImage.value"
:src="lightbox.currentImage.value.src"
:alt="lightbox.currentImage.value.alt || 'Lightbox image'"
container-class="flex items-center justify-center"
image-class="max-w-full max-h-[95vh] object-contain"
:blur-radius="8"
:transition-duration="400"
:show-loading-indicator="true"
/>
</div>
<!-- Close button -->
<Button
@click.stop="lightbox.close"
variant="ghost"
size="icon"
class="absolute top-4 right-4 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
aria-label="Close lightbox"
>
<X class="h-5 w-5" />
</Button>
<!-- Navigation buttons -->
<template v-if="lightbox.hasPrevious.value">
<Button
@click.stop="lightbox.goToPrevious"
variant="ghost"
size="icon"
class="absolute left-4 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
aria-label="Previous image"
>
<ChevronLeft class="h-6 w-6" />
</Button>
</template>
<template v-if="lightbox.hasNext.value">
<Button
@click.stop="lightbox.goToNext"
variant="ghost"
size="icon"
class="absolute right-4 top-1/2 -translate-y-1/2 bg-background/80 backdrop-blur hover:bg-background/90 text-foreground shadow-lg border border-border/50"
aria-label="Next image"
>
<ChevronRight class="h-6 w-6" />
</Button>
</template>
<!-- Image counter -->
<div
v-if="lightbox.totalImages.value > 1"
class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-background/80 backdrop-blur rounded-lg px-3 py-1.5 text-sm font-medium border border-border/50 shadow-lg"
>
{{ lightbox.currentIndex.value + 1 }} / {{ lightbox.totalImages.value }}
</div>
<!-- Keyboard navigation hint (visible for a few seconds) -->
<Transition
enter-active-class="transition-opacity duration-300"
leave-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="showKeyboardHint"
class="absolute top-4 left-1/2 -translate-x-1/2 bg-background/90 backdrop-blur rounded-lg px-4 py-2 text-sm text-muted-foreground border border-border/50 shadow-lg"
>
Use arrow keys or swipe to navigate ESC to close
</div>
</Transition>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, 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>

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

View file

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

View 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)
}

View file

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

View file

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

View file

@ -154,6 +154,15 @@ export const marketModule: ModulePlugin = {
title: 'Stall',
requiresAuth: false
}
},
{
path: '/market/product/:productId',
name: 'product-detail',
component: () => import('./views/ProductDetailPage.vue'),
meta: {
title: 'Product Details',
requiresAuth: false
}
}
] as RouteRecordRaw[],

View file

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