web-app/src/modules/market/views/StallView.vue
padreug 43c368e4e4 feat: add lazy loading to product images across market components
- 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.
2025-09-27 18:48:57 +02:00

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>