All checks were successful
Build Docker Image / build (push) Successful in 4m14s
- 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.
488 lines
14 KiB
Vue
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>
|