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

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