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:
parent
3aec5bbdb3
commit
ca0ac2b9ad
9 changed files with 15 additions and 266 deletions
|
|
@ -93,7 +93,7 @@ import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { X, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import ProgressiveImage from './ProgressiveImage.vue'
|
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 {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
|
|
@ -106,7 +106,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import ProgressiveImage from './ProgressiveImage.vue'
|
import ProgressiveImage from './ProgressiveImage.vue'
|
||||||
import ImageLightbox from './ImageLightbox.vue'
|
import ImageLightbox from './ImageLightbox.vue'
|
||||||
import type { LightboxImage, UseImageLightboxOptions } from '@/composables/useImageLightbox'
|
import type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
10
src/components/ui/image/index.ts
Normal file
10
src/components/ui/image/index.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Image Components
|
||||||
|
export { default as ImageLightbox } from './ImageLightbox.vue'
|
||||||
|
export { default as ImageViewer } from './ImageViewer.vue'
|
||||||
|
export { default as ProgressiveImage } from './ProgressiveImage.vue'
|
||||||
|
|
||||||
|
// Composables
|
||||||
|
export { useImageLightbox } from './composables/useImageLightbox'
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type { LightboxImage, UseImageLightboxOptions } from './composables/useImageLightbox'
|
||||||
|
|
@ -155,7 +155,7 @@ import { ref, computed, watch } from 'vue'
|
||||||
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardFooter, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
||||||
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
import { ShoppingCart, Package, ChevronLeft, ChevronRight } from 'lucide-vue-next'
|
||||||
import type { Product } from '@/modules/market/stores/market'
|
import type { Product } from '@/modules/market/stores/market'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -290,7 +290,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import ProgressiveImage from '@/components/ui/ProgressiveImage.vue'
|
import ProgressiveImage from '@/components/ui/image/ProgressiveImage.vue'
|
||||||
import {
|
import {
|
||||||
Package,
|
Package,
|
||||||
CheckCircle
|
CheckCircle
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
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 { ShoppingCart, Store, Plus, Minus, ArrowLeft } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/core/composables/useToast'
|
import { useToast } from '@/core/composables/useToast'
|
||||||
import type { Product } from '../types/market'
|
import type { Product } from '../types/market'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue