- Implemented lazy loading for product images in CartItem, CartSummary, ProductCard, ProductDetailDialog, CheckoutPage, and StallView components. - This enhancement improves performance by deferring the loading of off-screen images, resulting in faster initial page load times and a smoother user experience. These changes optimize image handling across the market module, contributing to better resource management and user interaction.
333 lines
No EOL
11 KiB
Vue
333 lines
No EOL
11 KiB
Vue
<template>
|
|
<div class="container mx-auto px-4 py-3 sm:py-6">
|
|
<!-- Stall Header -->
|
|
<div class="mb-4 sm:mb-8">
|
|
<!-- Back to Market Button -->
|
|
<Button
|
|
@click="goBackToMarket"
|
|
variant="ghost"
|
|
size="sm"
|
|
class="mb-2 sm:mb-4"
|
|
>
|
|
<ArrowLeft class="w-4 h-4 mr-2" />
|
|
Back to Market
|
|
</Button>
|
|
|
|
<!-- Compact Stall Info Card -->
|
|
<Card class="relative overflow-hidden border-l-4 border-l-primary/60 bg-gradient-to-r from-primary/5 via-background to-accent/5 shadow-md">
|
|
<div class="relative p-3 sm:p-4">
|
|
<div class="flex items-start gap-3">
|
|
<!-- Stall Logo (Enhanced) -->
|
|
<div class="flex-shrink-0">
|
|
<div v-if="stall?.logo" class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 border-2 border-primary/20 shadow-lg overflow-hidden ring-2 ring-primary/10">
|
|
<img
|
|
:src="stall.logo"
|
|
:alt="stall.name"
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
@error="handleLogoError"
|
|
/>
|
|
</div>
|
|
<div v-else class="w-12 h-12 sm:w-14 sm:h-14 rounded-xl bg-gradient-to-br from-primary/20 to-accent/20 flex items-center justify-center shadow-lg ring-2 ring-primary/10 border-2 border-primary/20">
|
|
<Store class="w-6 h-6 sm:w-7 sm:h-7 text-primary" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stall Info -->
|
|
<div class="flex-1 min-w-0">
|
|
<!-- Title and Description -->
|
|
<div class="mb-2">
|
|
<h1 class="text-lg sm:text-xl font-bold bg-gradient-to-r from-foreground to-foreground/80 bg-clip-text text-transparent truncate">{{ stall?.name || 'Unknown Stall' }}</h1>
|
|
<p v-if="stall?.description" class="text-sm text-muted-foreground/90 line-clamp-2 mt-1">
|
|
{{ stall.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Enhanced Stats Row -->
|
|
<div class="flex flex-wrap gap-2 mb-3">
|
|
<Badge class="text-xs font-medium bg-gradient-to-r from-primary to-primary/80 text-primary-foreground shadow-sm hover:shadow-md transition-shadow">
|
|
<span class="font-bold">{{ productCount }}</span>
|
|
<span class="ml-1 opacity-90">Products</span>
|
|
</Badge>
|
|
<Badge class="text-xs font-medium bg-gradient-to-r from-accent to-accent/80 text-accent-foreground shadow-sm">
|
|
<span class="font-bold">{{ stall?.currency || 'sats' }}</span>
|
|
<span class="ml-1 opacity-90">Currency</span>
|
|
</Badge>
|
|
<Badge v-if="stall?.shipping?.length" class="text-xs font-medium bg-gradient-to-r from-green-500 to-green-600 text-white shadow-sm">
|
|
<span class="font-bold">{{ stall.shipping.length }}</span>
|
|
<span class="ml-1 opacity-90">Shipping</span>
|
|
</Badge>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Category Filter Bar -->
|
|
<CategoryFilterBar
|
|
v-if="stallCategories.length > 0"
|
|
:categories="stallCategories"
|
|
:selected-count="selectedCategories.length"
|
|
:filter-mode="filterMode"
|
|
:product-count="productCount"
|
|
title="Categories"
|
|
@toggle-category="toggleCategoryFilter"
|
|
@set-filter-mode="setFilterMode"
|
|
@clear-filters="clearCategoryFilters"
|
|
class="mb-4 sm:mb-6"
|
|
/>
|
|
|
|
<!-- Search and Filter Bar -->
|
|
<div class="mb-4 sm:mb-6 flex flex-col sm:flex-row gap-2 sm:gap-4">
|
|
<div class="flex-1">
|
|
<MarketSearchBar
|
|
:data="stallProducts as Product[]"
|
|
:options="searchOptions"
|
|
placeholder="Search products in this stall..."
|
|
:show-enhancements="false"
|
|
@results="handleSearchResults"
|
|
class="w-full"
|
|
/>
|
|
</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>
|
|
|
|
<!-- Products Grid with Loading and Empty States -->
|
|
<ProductGrid
|
|
:products="filteredProducts as Product[]"
|
|
:is-loading="isLoading"
|
|
loading-message="Loading stall products..."
|
|
empty-title="No Products Found"
|
|
:empty-message="searchQuery || selectedCategories.length > 0
|
|
? 'Try adjusting your filters or search terms'
|
|
: 'This stall doesn\'t have any products yet'"
|
|
@view-stall="viewStall"
|
|
@add-to-cart="handleAddToCart"
|
|
/>
|
|
|
|
</div>
|
|
|
|
<!-- Cart Summary -->
|
|
<CartButton />
|
|
</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 } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { ArrowLeft, Store, X } from 'lucide-vue-next'
|
|
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
|
import ProductGrid from '../components/ProductGrid.vue'
|
|
import CartButton from '../components/CartButton.vue'
|
|
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
|
import type { Product, Stall } from '../types/market'
|
|
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const marketStore = useMarketStore()
|
|
|
|
// State
|
|
const isLoading = ref(false)
|
|
const searchResults = ref<Product[]>([])
|
|
const searchQuery = ref('')
|
|
const sortBy = ref('name')
|
|
const selectedCategories = ref<string[]>([])
|
|
const filterMode = ref<'any' | 'all'>('any')
|
|
const logoError = ref(false)
|
|
|
|
// Fuzzy search configuration for stall products
|
|
const searchOptions: FuzzySearchOptions<Product> = {
|
|
fuseOptions: {
|
|
keys: [
|
|
{ name: 'name', weight: 0.8 }, // Product name has highest weight in stall view
|
|
{ name: 'description', weight: 0.4 }, // Description is important for specific product search
|
|
{ name: 'categories', weight: 0.3 } // Categories for filtering within stall
|
|
],
|
|
threshold: 0.2, // More strict matching since we're within a single stall
|
|
ignoreLocation: true,
|
|
findAllMatches: true,
|
|
minMatchCharLength: 2,
|
|
shouldSort: true
|
|
},
|
|
resultLimit: 100, // Less restrictive limit for stall view
|
|
minSearchLength: 2,
|
|
matchAllWhenSearchEmpty: true
|
|
}
|
|
|
|
// Get stall ID from route params
|
|
const stallId = computed(() => route.params.stallId as string)
|
|
|
|
// Get stall data
|
|
const stall = computed(() => {
|
|
return marketStore.stalls.find(s => s.id === stallId.value) as Stall | undefined
|
|
})
|
|
|
|
// 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 categoryCount = new Map<string, number>()
|
|
stallProducts.value.forEach(product => {
|
|
product.categories?.forEach(cat => {
|
|
categoryCount.set(cat, (categoryCount.get(cat) || 0) + 1)
|
|
})
|
|
})
|
|
|
|
return Array.from(categoryCount.entries())
|
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
.map(([category, count]) => ({
|
|
category,
|
|
count,
|
|
selected: selectedCategories.value.includes(category)
|
|
}))
|
|
})
|
|
|
|
// Product count
|
|
const productCount = computed(() => stallProducts.value.length)
|
|
|
|
// Filtered and sorted products (using fuzzy search results when available)
|
|
const filteredProducts = computed(() => {
|
|
// Use search results if available, otherwise use all stall products
|
|
let products = searchResults.value.length > 0 || searchResults.value.length === 0
|
|
? [...searchResults.value]
|
|
: [...stallProducts.value]
|
|
|
|
// 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 setFilterMode = (mode: 'any' | 'all') => {
|
|
filterMode.value = mode
|
|
}
|
|
|
|
const clearCategoryFilters = () => {
|
|
selectedCategories.value = []
|
|
}
|
|
|
|
const clearFilters = () => {
|
|
selectedCategories.value = []
|
|
searchResults.value = []
|
|
searchQuery.value = ''
|
|
}
|
|
|
|
// Handle fuzzy search results
|
|
const handleSearchResults = (results: Product[]) => {
|
|
searchResults.value = results
|
|
// Extract search query from fuzzy search component if needed
|
|
// For now, we'll track it separately
|
|
}
|
|
|
|
const handleAddToCart = (product: Product, quantity?: number) => {
|
|
marketStore.addToStallCart(product, quantity || 1)
|
|
}
|
|
|
|
|
|
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()
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Add any custom styles here if needed */
|
|
</style> |