feat: introduce ImageLightbox and ImageViewer components for enhanced image handling

- Added ImageLightbox component to provide a modal view for images with navigation and keyboard support.
- Implemented ImageViewer component to display images with features like thumbnails, cycling controls, and lightbox integration.
- Updated ProgressiveImage component for improved loading and error handling.
- Refactored image imports in ProductCard, ProductDetailPage, and CheckoutPage to align with new component structure.

These changes significantly enhance the user experience for viewing and interacting with product images across the application.
This commit is contained in:
padreug 2025-09-28 12:48:02 +02:00
parent 3aec5bbdb3
commit ca0ac2b9ad
9 changed files with 15 additions and 266 deletions

View file

@ -93,7 +93,7 @@ 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'
import { useImageLightbox, type LightboxImage, type UseImageLightboxOptions } from './composables/useImageLightbox'
interface Props {
/**

View file

@ -106,7 +106,7 @@ 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'
import type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox'
interface Props {
/**

View file

@ -0,0 +1,10 @@
// Image Components
export { default as ImageLightbox } from './ImageLightbox.vue'
export { default as ImageViewer } from './ImageViewer.vue'
export { default as ProgressiveImage } from './ProgressiveImage.vue'
// Composables
export { useImageLightbox } from './composables/useImageLightbox'
// Types
export type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox'

View file

@ -155,7 +155,7 @@ 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 ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
import type { Product } from '@/modules/market/stores/market'

View file

@ -1,261 +0,0 @@
<template>
<Dialog :open="isOpen" @update:open="handleClose">
<DialogContent class="sm:max-w-4xl max-h-[90vh] overflow-y-auto">
<!-- Close Button -->
<DialogClose class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogClose>
<div class="grid gap-6 md:grid-cols-2">
<!-- Product Images 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"
:is-embedded="true"
@error="handleImageError"
@image-change="handleImageChange"
@lightbox-open="handleLightboxOpen"
@lightbox-close="handleLightboxClose"
/>
</div>
<!-- Product Details -->
<div class="space-y-6">
<!-- Title and Price -->
<div>
<h2 class="text-3xl font-bold mb-2">{{ product.name }}</h2>
<div class="flex items-baseline gap-4">
<span class="text-3xl font-bold text-green-600">
{{ formatPrice(product.price, product.currency) }}
</span>
<Badge v-if="product.quantity < 1" variant="destructive">
Out of Stock
</Badge>
<Badge v-else-if="product.quantity <= 5" variant="outline">
Only {{ product.quantity }} left
</Badge>
<Badge v-else variant="secondary">
In Stock
</Badge>
</div>
</div>
<!-- Stall Info -->
<div class="flex items-center gap-2 pb-4 border-b">
<Store class="w-4 h-4 text-gray-500" />
<span class="text-sm text-gray-600">Sold by</span>
<span class="font-medium">{{ product.stallName }}</span>
</div>
<!-- Description -->
<div v-if="product.description">
<h3 class="font-semibold mb-2">Description</h3>
<p class="text-gray-600 whitespace-pre-wrap">{{ product.description }}</p>
</div>
<!-- Categories -->
<div v-if="product.categories && product.categories.length > 0">
<h3 class="font-semibold mb-2">Categories</h3>
<div class="flex flex-wrap gap-2">
<Badge
v-for="category in product.categories"
:key="category"
variant="secondary"
>
{{ category }}
</Badge>
</div>
</div>
<!-- Add to Cart Section -->
<div class="space-y-4 pt-6 border-t">
<div class="flex items-center gap-4">
<Label for="quantity" class="text-sm font-medium">
Quantity:
</Label>
<div class="flex items-center gap-2">
<Button
@click="decrementQuantity"
:disabled="quantity <= 1"
size="sm"
variant="outline"
class="h-8 w-8 p-0"
>
<Minus class="h-4 w-4" />
</Button>
<Input
id="quantity"
v-model.number="quantity"
type="number"
:min="1"
:max="product.quantity || 999"
class="w-16 h-8 text-center"
/>
<Button
@click="incrementQuantity"
:disabled="quantity >= (product.quantity || 999)"
size="sm"
variant="outline"
class="h-8 w-8 p-0"
>
<Plus class="h-4 w-4" />
</Button>
</div>
</div>
<div class="flex gap-3">
<Button
@click="handleAddToCart"
:disabled="product.quantity < 1"
class="flex-1"
>
<ShoppingCart class="w-4 h-4 mr-2" />
Add to Cart
</Button>
<Button
@click="handleClose"
variant="outline"
class="flex-1"
>
Continue Shopping
</Button>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Dialog,
DialogContent,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import 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'
interface Props {
product: Product
isOpen: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
'add-to-cart': [product: Product, quantity: number]
}>()
const toast = useToast()
// Local state
const quantity = ref(1)
const imageLoadError = ref(false)
// Computed properties
const productImages = computed(() => {
if (!props.product.images || props.product.images.length === 0) {
return []
}
return props.product.images.filter(img => img && img.trim() !== '')
})
// Methods
const formatPrice = (price: number, currency: string) => {
if (currency === 'sat' || currency === 'sats') {
return `${price.toLocaleString('en-US')} sats`
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency.toUpperCase()
}).format(price)
}
const incrementQuantity = () => {
const max = props.product.quantity || 999
if (quantity.value < max) {
quantity.value++
}
}
const decrementQuantity = () => {
if (quantity.value > 1) {
quantity.value--
}
}
const handleAddToCart = () => {
emit('add-to-cart', props.product, quantity.value)
toast.success(`Added ${quantity.value} ${props.product.name} to cart`)
handleClose()
}
const handleClose = () => {
emit('close')
}
const handleImageError = (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')
}
// Reset state when dialog opens/closes
watch(() => props.isOpen, (newVal) => {
if (newVal) {
quantity.value = 1
imageLoadError.value = false
}
})
// Reset image error when product changes
watch(() => props.product?.id, () => {
imageLoadError.value = false
})
</script>
<style scoped>
/* Hide number input spinner buttons */
input[type="number"]::-webkit-inner-spin-button,
input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type="number"] {
-moz-appearance: textfield;
}
</style>

View file

@ -290,7 +290,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
import {
Package,
CheckCircle

View file

@ -166,7 +166,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 ImageViewer from '@/components/ui/ImageViewer.vue'
import ImageViewer from '@/components/ui/image/ImageViewer.vue'
import { ShoppingCart, Store, Plus, Minus, ArrowLeft } from 'lucide-vue-next'
import { useToast } from '@/core/composables/useToast'
import type { Product } from '../types/market'