613 lines
19 KiB
Vue
613 lines
19 KiB
Vue
<template>
|
|
<div>
|
|
<CatalogPage
|
|
ref="catalogPageRef"
|
|
:loading="isLoading"
|
|
:use-server-clustering="true"
|
|
:use-typed-clusters="true"
|
|
:cluster-node-type="clusterNodeType"
|
|
panel-width="w-[32rem]"
|
|
map-id="unified-catalog-map"
|
|
:point-color="mapPointColor"
|
|
:items="currentSelectionItems"
|
|
:hovered-id="hoveredItemId ?? undefined"
|
|
:show-panel="showPanel && !kycSheetUuid"
|
|
:filter-by-bounds="filterByBounds"
|
|
:related-points="relatedPoints"
|
|
:info-loading="isInfoLoading"
|
|
@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"
|
|
@pin="onPinItem"
|
|
@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"
|
|
@pin="onPinItem"
|
|
@open-info="onInfoOpenRelated"
|
|
@select-product="onInfoSelectProduct"
|
|
@select-offer="onSelectOffer"
|
|
@open-kyc="onOpenKyc"
|
|
/>
|
|
|
|
<!-- Quote results: show offers after search -->
|
|
<QuotePanel
|
|
v-else-if="showQuoteResults"
|
|
:loading="offersLoading"
|
|
:offers="offers"
|
|
@select-offer="onSelectOffer"
|
|
/>
|
|
</template>
|
|
</CatalogPage>
|
|
|
|
<!-- KYC Bottom Sheet (overlays everything) -->
|
|
<KycBottomSheet
|
|
:is-open="!!kycSheetUuid"
|
|
:uuid="kycSheetUuid"
|
|
@close="onCloseKycSheet"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { GetOffersDocument, GetOfferDocument, type GetOffersQueryVariables, type GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated'
|
|
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
|
|
|
// Offer type from search results
|
|
type OfferResult = NonNullable<NonNullable<GetOffersQueryResult['getOffers']>[number]>
|
|
|
|
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)
|
|
const selectionBoundsBackup = ref<{ hadBounds: boolean; bounds: { west: number; south: number; east: number; north: number } | null } | null>(null)
|
|
|
|
// Hovered item for map highlight
|
|
const hoveredItemId = ref<string | null>(null)
|
|
const onHoverItem = (uuid: string | null) => {
|
|
hoveredItemId.value = uuid
|
|
}
|
|
|
|
// Type for map items - must have required string uuid and number coordinates
|
|
type MapItemWithCoords = { uuid: string; name: string; latitude: number; longitude: number; country?: string }
|
|
|
|
// Helper to convert items to map-compatible format (filter null values)
|
|
const toMapItems = <T extends { uuid?: string | null; name?: string | null; latitude?: number | null; longitude?: number | null }>(
|
|
items: T[]
|
|
): MapItemWithCoords[] =>
|
|
items.filter((item): item is T & { uuid: string; latitude: number; longitude: number } =>
|
|
item.uuid != null && item.latitude != null && item.longitude != null
|
|
).map(item => ({
|
|
uuid: item.uuid,
|
|
name: item.name || '',
|
|
latitude: item.latitude,
|
|
longitude: item.longitude
|
|
}))
|
|
|
|
// Current selection items for hover highlighting on map
|
|
const currentSelectionItems = computed((): MapItemWithCoords[] => {
|
|
if (selectMode.value === 'product') return [] // Products don't have coordinates
|
|
if (selectMode.value === 'hub') return toMapItems(filteredHubs.value)
|
|
if (selectMode.value === 'supplier') return toMapItems(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()
|
|
}
|
|
|
|
const getSelectionBounds = () => {
|
|
const bounds = currentMapBounds.value ?? catalogPageRef.value?.currentBounds?.value ?? null
|
|
if (!bounds) return null
|
|
return { west: bounds.west, south: bounds.south, east: bounds.east, north: bounds.north }
|
|
}
|
|
|
|
const applySelectionBounds = () => {
|
|
if (!selectionBoundsBackup.value) {
|
|
selectionBoundsBackup.value = {
|
|
hadBounds: !!urlBounds.value,
|
|
bounds: urlBounds.value
|
|
}
|
|
}
|
|
const bounds = getSelectionBounds()
|
|
if (bounds) {
|
|
setBoundsInUrl(bounds)
|
|
}
|
|
}
|
|
|
|
const restoreSelectionBounds = () => {
|
|
const prev = selectionBoundsBackup.value
|
|
if (!prev) return
|
|
if (prev.hadBounds && prev.bounds) {
|
|
setBoundsInUrl(prev.bounds)
|
|
} else {
|
|
clearBoundsFromUrl()
|
|
}
|
|
selectionBoundsBackup.value = null
|
|
}
|
|
|
|
// Initialize data and sync map view when selectMode changes
|
|
watch(selectMode, async (mode) => {
|
|
if (mode) {
|
|
applySelectionBounds()
|
|
} else {
|
|
restoreSelectionBounds()
|
|
}
|
|
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 current entity + 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 current entity first (the one we're viewing in InfoPanel)
|
|
// For offers, coordinates are in locationLatitude/locationLongitude
|
|
const lat = entity.value?.latitude ?? entity.value?.locationLatitude
|
|
const lon = entity.value?.longitude ?? entity.value?.locationLongitude
|
|
if (lat && lon) {
|
|
points.push({
|
|
uuid: infoId.value.uuid,
|
|
name: entity.value.name || entity.value.productName || '',
|
|
latitude: Number(lat),
|
|
longitude: Number(lon),
|
|
type: infoId.value.type
|
|
})
|
|
}
|
|
|
|
// Add all related hubs
|
|
relatedHubs.value.forEach(hub => {
|
|
if (hub.uuid && hub.latitude && hub.longitude) {
|
|
points.push({
|
|
uuid: hub.uuid,
|
|
name: hub.name || '',
|
|
latitude: hub.latitude,
|
|
longitude: hub.longitude,
|
|
type: 'hub'
|
|
})
|
|
}
|
|
})
|
|
|
|
// Add all related suppliers
|
|
relatedSuppliers.value.forEach(supplier => {
|
|
if (supplier.uuid && 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<OfferResult[]>([])
|
|
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)
|
|
|
|
// Info loading state for map fitBounds (true while any info data is still loading)
|
|
const isInfoLoading = computed(() =>
|
|
infoLoading.value || isLoadingProducts.value || isLoadingHubs.value || isLoadingSuppliers.value || isLoadingOffers.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
|
|
})
|
|
|
|
// Map item type from CatalogMap
|
|
interface MapSelectItem {
|
|
uuid?: string | null
|
|
id?: string
|
|
name?: string | null
|
|
}
|
|
|
|
// Handle map item selection
|
|
const onMapSelect = async (item: MapSelectItem) => {
|
|
// 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) + '...'
|
|
|
|
const itemType = (item as MapSelectItem & { type?: 'hub' | 'supplier' | 'offer' }).type
|
|
|
|
// 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' && (itemType === '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' && (itemType === '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' && (itemType === 'offer' || 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 (itemType === 'hub' || mapViewMode.value === 'hubs') infoType = 'hub'
|
|
else if (itemType === 'supplier' || 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: { uuid?: string | null; name?: string | null }) => {
|
|
if (item.uuid && item.name) {
|
|
selectItem(type, item.uuid, item.name)
|
|
}
|
|
}
|
|
|
|
const onPinItem = (type: string, item: { uuid?: string | null; name?: string | null }) => {
|
|
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 AND hub to filter
|
|
if (type === 'offer') {
|
|
if (entity.value.productUuid) {
|
|
const productName = entity.value.productName || entity.value.name || uuid.slice(0, 8) + '...'
|
|
selectItem('product', entity.value.productUuid, productName)
|
|
}
|
|
// Also add hub (location) to filter if available
|
|
if (entity.value.locationUuid) {
|
|
const hubName = entity.value.locationName || entity.value.locationUuid.slice(0, 8) + '...'
|
|
selectItem('hub', entity.value.locationUuid, hubName)
|
|
}
|
|
} 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)
|
|
}
|
|
|
|
// KYC Bottom Sheet state
|
|
const kycSheetUuid = ref<string | null>(null)
|
|
|
|
// Handle KYC profile open - show bottom sheet instead of navigating
|
|
const onOpenKyc = (uuid: string | undefined) => {
|
|
if (!uuid) return
|
|
kycSheetUuid.value = uuid
|
|
}
|
|
|
|
// Close KYC bottom sheet
|
|
const onCloseKycSheet = () => {
|
|
kycSheetUuid.value = null
|
|
}
|
|
|
|
// Search for offers
|
|
const onSearch = async () => {
|
|
if (!canSearch.value) return
|
|
|
|
offersLoading.value = true
|
|
showQuoteResults.value = true
|
|
|
|
try {
|
|
const vars: GetOffersQueryVariables = {}
|
|
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 || []).filter((o): o is OfferResult => o !== null)
|
|
|
|
// Update labels from response
|
|
const first = offers.value[0]
|
|
if (first) {
|
|
if (productId.value && first.productName) {
|
|
setLabel('product', productId.value, first.productName)
|
|
}
|
|
if (hubId.value && first.locationName) {
|
|
setLabel('hub', hubId.value, first.locationName)
|
|
}
|
|
// Note: teamName not included in GetOffers query, supplier label cannot be updated from offer
|
|
}
|
|
} finally {
|
|
offersLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Select offer - navigate to detail page
|
|
const onSelectOffer = (offer: { uuid: string; productUuid?: string | null }) => {
|
|
if (offer.uuid && offer.productUuid) {
|
|
router.push(localePath(`/catalog/offers/${offer.productUuid}?offer=${offer.uuid}`))
|
|
}
|
|
}
|
|
|
|
// SEO
|
|
useHead(() => ({
|
|
title: t('catalog.hero.title')
|
|
}))
|
|
</script>
|