Add stall view and product detail dialog in market module

- Introduced a new route for viewing individual stalls, allowing users to navigate to a specific stall's page.
- Created a ProductDetailDialog component to display detailed information about products, including images, descriptions, and stock status.
- Updated MarketPage to handle stall navigation and integrate the new dialog for product details.

These enhancements improve the user experience by providing more detailed product information and easier navigation within the market module.
This commit is contained in:
padreug 2025-09-25 22:53:12 +02:00
parent f2080abce5
commit 86d3133978
4 changed files with 601 additions and 0 deletions

View file

@ -0,0 +1,267 @@
<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 -->
<div class="space-y-4">
<!-- Main Image -->
<div class="aspect-square rounded-lg overflow-hidden bg-gray-100">
<img
v-if="currentImage"
:src="currentImage"
:alt="product.name"
class="w-full h-full object-cover"
@error="handleImageError"
/>
<div v-else class="w-full h-full flex items-center justify-center">
<Package class="w-24 h-24 text-gray-300" />
</div>
</div>
<!-- Image Thumbnails -->
<div v-if="productImages.length > 1" class="flex gap-2 overflow-x-auto">
<button
v-for="(image, index) in productImages"
:key="index"
@click="currentImageIndex = index"
class="relative w-20 h-20 rounded-lg overflow-hidden border-2 transition-all"
:class="currentImageIndex === index ? 'border-primary' : 'border-gray-200 hover:border-gray-400'"
>
<img
:src="image"
:alt="`${product.name} - Image ${index + 1}`"
class="w-full h-full object-cover"
/>
</button>
</div>
</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 { Package, 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 currentImageIndex = ref(0)
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() !== '')
})
const currentImage = computed(() => {
if (productImages.value.length === 0) {
return null
}
return productImages.value[currentImageIndex.value]
})
// 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 = () => {
imageLoadError.value = true
}
// Reset state when dialog opens/closes
watch(() => props.isOpen, (newVal) => {
if (newVal) {
quantity.value = 1
currentImageIndex.value = 0
imageLoadError.value = false
}
})
// Reset image index when product changes
watch(() => props.product?.id, () => {
currentImageIndex.value = 0
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>

View file

@ -145,6 +145,15 @@ export const marketModule: ModulePlugin = {
title: 'Checkout',
requiresAuth: false
}
},
{
path: '/market/stall/:stallId',
name: 'stall-view',
component: () => import('./views/StallView.vue'),
meta: {
title: 'Stall',
requiresAuth: false
}
}
] as RouteRecordRaw[],

View file

@ -81,6 +81,7 @@
:product="product"
@add-to-cart="addToCart"
@view-details="viewProduct"
@view-stall="viewStall"
/>
</div>
@ -164,6 +165,11 @@ const viewProduct = (_product: any) => {
// TODO: Navigate to product detail page
}
const viewStall = (stallId: string) => {
// Navigate to the stall view page
router.push(`/market/stall/${stallId}`)
}
const viewCart = () => {
router.push('/cart')
}

View file

@ -0,0 +1,319 @@
<template>
<div class="container mx-auto px-4 py-6">
<!-- Stall Header -->
<div class="mb-8">
<!-- Back to Market Button -->
<Button
@click="goBackToMarket"
variant="ghost"
size="sm"
class="mb-4"
>
<ArrowLeft class="w-4 h-4 mr-2" />
Back to Market
</Button>
<!-- Stall Info Card -->
<Card class="overflow-hidden">
<div class="relative h-48 bg-gradient-to-r from-blue-500 to-purple-600">
<!-- Stall Banner/Logo -->
<div class="absolute inset-0 flex items-center justify-center">
<div v-if="stall?.logo" class="w-32 h-32 rounded-full bg-white p-2 shadow-lg">
<img
:src="stall.logo"
:alt="stall.name"
class="w-full h-full object-contain rounded-full"
@error="handleLogoError"
/>
</div>
<div v-else class="w-32 h-32 rounded-full bg-white/90 flex items-center justify-center shadow-lg">
<Store class="w-16 h-16 text-gray-600" />
</div>
</div>
</div>
<CardContent class="pt-20 pb-6">
<div class="text-center">
<h1 class="text-3xl font-bold mb-2">{{ stall?.name || 'Unknown Stall' }}</h1>
<p v-if="stall?.description" class="text-gray-600 mb-4 max-w-2xl mx-auto">
{{ stall.description }}
</p>
<!-- Stall Stats -->
<div class="flex justify-center gap-8 mt-6">
<div class="text-center">
<div class="text-2xl font-bold text-blue-600">{{ productCount }}</div>
<div class="text-sm text-gray-600">Products</div>
</div>
<div class="text-center">
<div class="text-2xl font-bold text-green-600">{{ stall?.currency || 'sats' }}</div>
<div class="text-sm text-gray-600">Currency</div>
</div>
<div v-if="stall?.shipping?.length" class="text-center">
<div class="text-2xl font-bold text-purple-600">{{ stall.shipping.length }}</div>
<div class="text-sm text-gray-600">Shipping Zones</div>
</div>
</div>
<!-- Categories -->
<div v-if="stallCategories.length > 0" class="mt-6">
<div class="flex flex-wrap justify-center gap-2">
<Badge
v-for="category in stallCategories"
:key="category"
variant="secondary"
class="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
@click="toggleCategoryFilter(category)"
:class="{ 'bg-primary text-primary-foreground': selectedCategories.includes(category) }"
>
{{ category }}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Search and Filter Bar -->
<div class="mb-6 flex flex-col sm:flex-row gap-4">
<div class="flex-1">
<div class="relative">
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
v-model="searchQuery"
placeholder="Search products in this stall..."
class="pl-10"
/>
</div>
</div>
<div class="flex gap-2">
<Select v-model="sortBy">
<SelectTrigger class="w-[180px]">
<SelectValue placeholder="Sort by..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="price-asc">Price: Low to High</SelectItem>
<SelectItem value="price-desc">Price: High to Low</SelectItem>
<SelectItem value="newest">Newest First</SelectItem>
</SelectContent>
</Select>
<Button
v-if="selectedCategories.length > 0"
@click="clearFilters"
variant="outline"
size="sm"
>
Clear Filters
<X class="w-4 h-4 ml-1" />
</Button>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center h-64">
<Loader2 class="w-12 h-12 animate-spin text-primary" />
</div>
<!-- Empty State -->
<div v-else-if="!filteredProducts.length" class="text-center py-12">
<Package class="w-24 h-24 text-gray-300 mx-auto mb-4" />
<h2 class="text-xl font-semibold mb-2">No Products Found</h2>
<p class="text-gray-600">
{{ searchQuery || selectedCategories.length > 0
? 'Try adjusting your filters or search terms'
: 'This stall doesn\'t have any products yet' }}
</p>
</div>
<!-- Products Grid -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<ProductCard
v-for="product in filteredProducts"
:key="product.id"
:product="product"
@view-details="viewProductDetails"
@view-stall="viewStall"
/>
</div>
<!-- Product Detail Dialog -->
<ProductDetailDialog
v-if="selectedProduct"
:product="selectedProduct"
:isOpen="showProductDetail"
@close="closeProductDetail"
@add-to-cart="handleAddToCart"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMarketStore } from '@/modules/market/stores/market'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ArrowLeft, Store, Search, Package, Loader2, X } from 'lucide-vue-next'
import ProductCard from '../components/ProductCard.vue'
import ProductDetailDialog from '../components/ProductDetailDialog.vue'
import type { Product, Stall } from '../types/market'
const route = useRoute()
const router = useRouter()
const marketStore = useMarketStore()
// State
const isLoading = ref(false)
const searchQuery = ref('')
const sortBy = ref('name')
const selectedCategories = ref<string[]>([])
const showProductDetail = ref(false)
const selectedProduct = ref<Product | null>(null)
const logoError = ref(false)
// Get stall ID from route params
const stallId = computed(() => route.params.stallId as string)
// Get stall data
const stall = computed<Stall | undefined>(() => {
return marketStore.stalls.find(s => s.id === stallId.value)
})
// Get products for this stall
const stallProducts = computed(() => {
return marketStore.products.filter(p => p.stall_id === stallId.value)
})
// Get unique categories for this stall
const stallCategories = computed(() => {
const categories = new Set<string>()
stallProducts.value.forEach(product => {
product.categories?.forEach(cat => categories.add(cat))
})
return Array.from(categories).sort()
})
// Product count
const productCount = computed(() => stallProducts.value.length)
// Filtered and sorted products
const filteredProducts = computed(() => {
let products = [...stallProducts.value]
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
products = products.filter(p =>
p.name.toLowerCase().includes(query) ||
p.description?.toLowerCase().includes(query)
)
}
// Filter by selected categories
if (selectedCategories.value.length > 0) {
products = products.filter(p =>
p.categories?.some(cat => selectedCategories.value.includes(cat))
)
}
// Sort products
switch (sortBy.value) {
case 'price-asc':
products.sort((a, b) => a.price - b.price)
break
case 'price-desc':
products.sort((a, b) => b.price - a.price)
break
case 'newest':
products.sort((a, b) => b.createdAt - a.createdAt)
break
case 'name':
default:
products.sort((a, b) => a.name.localeCompare(b.name))
}
return products
})
// Methods
const goBackToMarket = () => {
router.push('/market')
}
const toggleCategoryFilter = (category: string) => {
const index = selectedCategories.value.indexOf(category)
if (index >= 0) {
selectedCategories.value.splice(index, 1)
} else {
selectedCategories.value.push(category)
}
}
const clearFilters = () => {
selectedCategories.value = []
searchQuery.value = ''
}
const viewProductDetails = (product: Product) => {
selectedProduct.value = product
showProductDetail.value = true
}
const closeProductDetail = () => {
showProductDetail.value = false
selectedProduct.value = null
}
const handleAddToCart = (product: Product, quantity: number) => {
marketStore.addToStallCart(product, quantity)
closeProductDetail()
}
const viewStall = (otherStallId: string) => {
if (otherStallId !== stallId.value) {
router.push(`/market/stall/${otherStallId}`)
}
}
const handleLogoError = () => {
logoError.value = true
}
// Load stall data if needed
onMounted(async () => {
if (!stall.value) {
isLoading.value = true
// You might want to load the stall data here if it's not already loaded
// For now, we'll assume it's already in the store
setTimeout(() => {
isLoading.value = false
}, 1000)
}
})
// Watch for route changes to update the view
watch(() => route.params.stallId, (newStallId) => {
if (newStallId && newStallId !== stallId.value) {
// Reset filters when navigating to a different stall
clearFilters()
closeProductDetail()
}
})
</script>
<style scoped>
/* Add any custom styles here if needed */
</style>