Files
webapp/app/pages/catalog/index.vue
Ruslan Bakiev 19aca61845
All checks were successful
Build Docker Image / build (push) Successful in 4m14s
fix(catalog): prevent unnecessary list reloads on map movement
- Remove currentMapBounds from watch - it changes on every map move
- Watch only filterByBounds and urlBounds (URL-based state)
- Add early return in setBoundsFilter if bounds haven't changed

This fixes the issue where the list was reloading on every map movement
even when the 'filter by bounds' checkbox was OFF.
2026-01-26 22:24:47 +07:00

488 lines
14 KiB
Vue

<template>
<CatalogPage
ref="catalogPageRef"
:loading="isLoading"
:use-server-clustering="true"
:cluster-node-type="clusterNodeType"
map-id="unified-catalog-map"
:point-color="mapPointColor"
:items="currentSelectionItems"
:hovered-id="hoveredItemId ?? undefined"
:show-panel="showPanel"
:filter-by-bounds="filterByBounds"
:related-points="relatedPoints"
@select="onMapSelect"
@bounds-change="onBoundsChange"
@update:filter-by-bounds="$event ? setBoundsInUrl(currentMapBounds) : clearBoundsFromUrl()"
>
<!-- Panel slot - shows selection list OR info OR quote results -->
<template #panel>
<!-- Selection mode: show list for picking product/hub/supplier -->
<SelectionPanel
v-if="selectMode"
:select-mode="selectMode"
:products="filteredProducts"
:hubs="filteredHubs"
:suppliers="filteredSuppliers"
:loading="selectionLoading"
:loading-more="selectionLoadingMore"
:has-more="selectionHasMore && !filterByBounds"
@select="onSelectItem"
@close="onClosePanel"
@load-more="onLoadMore"
@hover="onHoverItem"
/>
<!-- Info mode: show detailed info about selected entity -->
<InfoPanel
v-else-if="infoId"
:entity-type="infoId.type"
:entity-id="infoId.uuid"
:entity="entity"
:related-products="relatedProducts"
:related-hubs="relatedHubs"
:related-suppliers="relatedSuppliers"
:related-offers="relatedOffers"
:selected-product="infoProduct ?? null"
:loading="infoLoading"
:loading-products="isLoadingProducts"
:loading-hubs="isLoadingHubs"
:loading-suppliers="isLoadingSuppliers"
:loading-offers="isLoadingOffers"
@close="onInfoClose"
@add-to-filter="onInfoAddToFilter"
@open-info="onInfoOpenRelated"
@select-product="onInfoSelectProduct"
/>
<!-- Quote results: show offers after search -->
<QuotePanel
v-else-if="showQuoteResults"
:loading="offersLoading"
:offers="offers"
@select-offer="onSelectOffer"
/>
</template>
</CatalogPage>
</template>
<script setup lang="ts">
import { GetOffersDocument, GetOfferDocument } from '~/composables/graphql/public/exchange-generated'
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
definePageMeta({
layout: 'topnav'
})
const { t } = useI18n()
const { execute } = useGraphQL()
const router = useRouter()
const localePath = useLocalePath()
// Ref to CatalogPage for accessing bounds
const catalogPageRef = ref<{ currentBounds: Ref<MapBounds | null> } | null>(null)
// Current map bounds (local state, updated when map moves)
const currentMapBounds = ref<MapBounds | null>(null)
// Hovered item for map highlight
const hoveredItemId = ref<string | null>(null)
const onHoverItem = (uuid: string | null) => {
hoveredItemId.value = uuid
}
// Current selection items for hover highlighting on map
const currentSelectionItems = computed(() => {
if (selectMode.value === 'product') return filteredProducts.value
if (selectMode.value === 'hub') return filteredHubs.value
if (selectMode.value === 'supplier') return filteredSuppliers.value
return []
})
// Handle bounds change from map
const onBoundsChange = (bounds: MapBounds) => {
currentMapBounds.value = bounds
// If filter by bounds is enabled, write to URL
if (filterByBounds.value) {
setBoundsInUrl(bounds)
}
}
const {
catalogMode,
selectMode,
infoId,
infoProduct,
productId,
supplierId,
hubId,
canSearch,
mapViewMode,
setMapViewMode,
entityColors,
selectItem,
cancelSelect,
openInfo,
closeInfo,
setInfoProduct,
setLabel,
urlBounds,
filterByBounds,
setBoundsInUrl,
clearBoundsFromUrl
} = useCatalogSearch()
// Info panel composable
const {
entity,
relatedProducts,
relatedHubs,
relatedSuppliers,
relatedOffers,
selectedProduct,
isLoading: infoLoading,
isLoadingProducts,
isLoadingHubs,
isLoadingSuppliers,
isLoadingOffers,
loadInfo,
selectProduct: selectInfoProduct,
clearInfo
} = useCatalogInfo()
// Composables for data (initialize immediately when selectMode changes)
const {
items: products,
isLoading: productsLoading,
isLoadingMore: productsLoadingMore,
canLoadMore: productsCanLoadMore,
loadMore: loadMoreProducts,
init: initProducts,
setSupplierFilter,
setHubFilter,
clearFilters: clearProductFilters,
setBoundsFilter: setProductBoundsFilter
} = useCatalogProducts()
const { items: hubs, isLoading: hubsLoading, isLoadingMore: hubsLoadingMore, canLoadMore: hubsCanLoadMore, loadMore: loadMoreHubs, init: initHubs, setProductFilter: setHubProductFilter, setBoundsFilter: setHubBoundsFilter } = useCatalogHubs()
const { items: suppliers, isLoading: suppliersLoading, isLoadingMore: suppliersLoadingMore, canLoadMore: suppliersCanLoadMore, loadMore: loadMoreSuppliers, init: initSuppliers, setProductFilter: setSupplierProductFilter, setBoundsFilter: setSupplierBoundsFilter } = useCatalogSuppliers()
// Items are now filtered on the backend via setBoundsFilter
// These are simple pass-throughs to maintain template compatibility
const filteredProducts = computed(() => products.value)
const filteredHubs = computed(() => hubs.value)
const filteredSuppliers = computed(() => suppliers.value)
// Selection loading state
const selectionLoading = computed(() => {
if (selectMode.value === 'product') return productsLoading.value
if (selectMode.value === 'hub') return hubsLoading.value
if (selectMode.value === 'supplier') return suppliersLoading.value
return false
})
// Selection loading more state
const selectionLoadingMore = computed(() => {
if (selectMode.value === 'product') return productsLoadingMore.value
if (selectMode.value === 'hub') return hubsLoadingMore.value
if (selectMode.value === 'supplier') return suppliersLoadingMore.value
return false
})
// Selection has more state
const selectionHasMore = computed(() => {
if (selectMode.value === 'product') return productsCanLoadMore.value
if (selectMode.value === 'hub') return hubsCanLoadMore.value
if (selectMode.value === 'supplier') return suppliersCanLoadMore.value
return false
})
// Load more handler
const onLoadMore = () => {
if (selectMode.value === 'product') loadMoreProducts()
if (selectMode.value === 'hub') loadMoreHubs()
if (selectMode.value === 'supplier') loadMoreSuppliers()
}
// Initialize data and sync map view when selectMode changes
watch(selectMode, async (mode) => {
if (mode === 'product') {
await initProducts()
setMapViewMode('offers')
}
if (mode === 'hub') {
await initHubs()
setMapViewMode('hubs')
}
if (mode === 'supplier') {
await initSuppliers()
setMapViewMode('suppliers')
}
}, { immediate: true })
// Apply filters to products based on selected supplier/hub
watch([supplierId, hubId], ([newSupplierId, newHubId]) => {
if (newSupplierId) {
setSupplierFilter(newSupplierId)
} else if (newHubId) {
setHubFilter(newHubId)
} else {
clearProductFilters()
}
}, { immediate: true })
// Apply product filter to hubs and suppliers (cascading filter)
watch(productId, (newProductId) => {
setHubProductFilter(newProductId || null)
setSupplierProductFilter(newProductId || null)
}, { immediate: true })
// Apply bounds filter when "filter by map bounds" is enabled
// Only watch URL bounds - currentMapBounds changes too often (every map move)
watch([filterByBounds, urlBounds], ([enabled, urlB]) => {
// Apply bounds filter only when checkbox is ON and bounds are in URL
const boundsToApply = enabled && urlB ? urlB : null
setHubBoundsFilter(boundsToApply)
setSupplierBoundsFilter(boundsToApply)
setProductBoundsFilter(boundsToApply)
}, { immediate: true })
// Watch infoId to load info data
watch(infoId, async (info) => {
if (info) {
await loadInfo(info.type, info.uuid)
// If URL has infoProduct, load offers for it
if (infoProduct.value) {
await selectInfoProduct(infoProduct.value)
}
} else {
clearInfo()
}
}, { immediate: true })
// Watch infoProduct URL param to load offers when it changes
watch(infoProduct, async (productUuid) => {
if (productUuid && infoId.value) {
await selectInfoProduct(productUuid)
}
})
// Related points for Info mode (shown on map) - show all related entities
const relatedPoints = computed(() => {
if (!infoId.value) return []
const points: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}> = []
// Add all hubs
relatedHubs.value.forEach(hub => {
if (hub.latitude && hub.longitude) {
points.push({
uuid: hub.uuid,
name: hub.name,
latitude: hub.latitude,
longitude: hub.longitude,
type: 'hub'
})
}
})
// Add all suppliers
relatedSuppliers.value.forEach(supplier => {
if (supplier.latitude && supplier.longitude) {
points.push({
uuid: supplier.uuid,
name: supplier.name,
latitude: supplier.latitude,
longitude: supplier.longitude,
type: 'supplier'
})
}
})
return points
})
// Offers data for quote results
const offers = ref<any[]>([])
const offersLoading = ref(false)
const showQuoteResults = ref(false)
// Watch for search trigger from topnav
const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
watch(searchTrigger, () => {
if (canSearch.value) {
onSearch()
}
})
// Loading state
const isLoading = computed(() => offersLoading.value || selectionLoading.value)
// Show panel when selecting OR when showing info OR when showing quote results
const showPanel = computed(() => {
return selectMode.value !== null || infoId.value !== null || showQuoteResults.value
})
// Cluster node type based on map view mode
const clusterNodeType = computed(() => {
if (mapViewMode.value === 'offers') return 'offer'
if (mapViewMode.value === 'hubs') return 'logistics'
if (mapViewMode.value === 'suppliers') return 'supplier'
return 'offer'
})
// Map point color based on map view mode
const mapPointColor = computed(() => {
if (mapViewMode.value === 'offers') return entityColors.offer
if (mapViewMode.value === 'hubs') return entityColors.hub
if (mapViewMode.value === 'suppliers') return entityColors.supplier
return entityColors.offer
})
// Handle map item selection
const onMapSelect = async (item: any) => {
// Get uuid from item - clusters use 'id', regular items use 'uuid'
const itemId = item.uuid || item.id
if (!itemId || itemId.startsWith('cluster-')) return
const itemName = item.name || itemId.slice(0, 8) + '...'
// If in selection mode, use map click to fill the selector
if (selectMode.value) {
// For hubs selection - click on hub fills hub selector
if (selectMode.value === 'hub' && mapViewMode.value === 'hubs') {
selectItem('hub', itemId, itemName)
showQuoteResults.value = false
offers.value = []
return
}
// For supplier selection - click on supplier fills supplier selector
if (selectMode.value === 'supplier' && mapViewMode.value === 'suppliers') {
selectItem('supplier', itemId, itemName)
showQuoteResults.value = false
offers.value = []
return
}
// For product selection viewing offers - fetch offer to get productUuid
if (selectMode.value === 'product' && mapViewMode.value === 'offers') {
// Fetch offer details to get productUuid (not available in cluster data)
const data = await execute(GetOfferDocument, { uuid: itemId }, 'public', 'exchange')
const offer = data?.getOffer
if (offer?.productUuid) {
selectItem('product', offer.productUuid, offer.productName || itemName)
showQuoteResults.value = false
offers.value = []
}
return
}
}
// NEW: Default behavior - open Info directly
let infoType: 'hub' | 'supplier' | 'offer'
if (mapViewMode.value === 'hubs') infoType = 'hub'
else if (mapViewMode.value === 'suppliers') infoType = 'supplier'
else infoType = 'offer'
openInfo(infoType, itemId)
setLabel(infoType, itemId, itemName)
}
// Handle selection from SelectionPanel - add to filter (show badge in search)
const onSelectItem = (type: string, item: any) => {
if (item.uuid && item.name) {
selectItem(type, item.uuid, item.name)
}
}
// Close panel (cancel select mode)
const onClosePanel = () => {
cancelSelect()
}
// Handle Info panel events
const onInfoClose = () => {
closeInfo()
clearInfo()
}
const onInfoAddToFilter = () => {
if (!infoId.value || !entity.value) return
const { type, uuid } = infoId.value
// For offers, add the product to filter (not the offer itself)
if (type === 'offer' && entity.value.productUuid) {
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
selectItem('product', entity.value.productUuid, productName)
} else {
// For hubs and suppliers, add directly
const name = entity.value.name || uuid.slice(0, 8) + '...'
selectItem(type, uuid, name)
}
closeInfo()
clearInfo()
}
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
openInfo(type, uuid)
}
// Handle product selection in InfoPanel - update URL which triggers loading
const onInfoSelectProduct = (uuid: string | null) => {
setInfoProduct(uuid)
}
// Search for offers
const onSearch = async () => {
if (!canSearch.value) return
offersLoading.value = true
showQuoteResults.value = true
try {
const vars: any = {}
if (productId.value) vars.productUuid = productId.value
if (supplierId.value) vars.teamUuid = supplierId.value
if (hubId.value) vars.locationUuid = hubId.value
const data = await execute(GetOffersDocument, vars, 'public', 'exchange')
offers.value = data?.getOffers || []
// Update labels from response
if (offers.value.length > 0) {
const first = offers.value[0]
if (productId.value && first.productName) {
setLabel('product', productId.value, first.productName)
}
if (hubId.value && first.locationName) {
setLabel('hub', hubId.value, first.locationName)
}
if (supplierId.value && first.teamName) {
setLabel('supplier', supplierId.value, first.teamName)
}
}
} finally {
offersLoading.value = false
}
}
// Select offer - navigate to detail page
const onSelectOffer = (offer: any) => {
if (offer.uuid && offer.productUuid) {
router.push(localePath(`/catalog/offers/${offer.productUuid}?offer=${offer.uuid}`))
}
}
// SEO
useHead(() => ({
title: t('catalog.hero.title')
}))
</script>