- 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.
314 lines
No EOL
9 KiB
Vue
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> |