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