web-app/src/modules/market/views/ProductDetailPage.vue
padreug 98934ed61d refactor: streamline ImageLightbox and update ProductDetailPage for better image handling
- Removed unused `closeOnBackdropClick` option from `useImageLightbox` for cleaner code.
- Simplified the product assignment in `ProductDetailPage` by creating a mutable copy of product data, ensuring proper handling of images and categories.

These changes enhance the maintainability and clarity of the image handling components in the application.
2025-09-28 12:58:11 +02:00

314 lines
No EOL
9 KiB
Vue

<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/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'
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')
}
// Create a mutable copy to satisfy type requirements
product.value = {
...productData,
images: productData.images ? [...productData.images] : undefined,
categories: productData.categories ? [...productData.categories] : undefined
}
// 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>