Files
webapp/app/pages/catalog/index.vue
Ruslan Bakiev 6b9935e8e8
Some checks failed
Build Docker Image / build (push) Has been cancelled
Align supplier map with product filter list
2026-02-07 13:00:53 +07:00

765 lines
24 KiB
Vue

<template>
<div>
<CatalogPage
ref="catalogPageRef"
:loading="isLoading"
:use-server-clustering="useServerClustering"
:use-typed-clusters="useServerClustering"
: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="mapInfoLoading"
:force-info-mode="forceInfoMode"
:hide-view-toggle="hideViewToggle"
:show-offers-toggle="showOffersToggle"
:show-hubs-toggle="showHubsToggle"
:show-suppliers-toggle="showSuppliersToggle"
:cluster-product-uuid="clusterProductUuid"
:cluster-hub-uuid="clusterHubUuid"
:cluster-supplier-uuid="clusterSupplierUuid"
@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"
@open-info="onInfoOpenRelated"
@select-product="onInfoSelectProduct"
@select-offer="onSelectOffer"
@open-kyc="onOpenKyc"
@pin="onPinItem"
/>
<!-- Quote results: show offers after search -->
<QuotePanel
v-else-if="showQuoteResults"
:loading="offersLoading"
:offers="offers"
:calculations="quoteCalculations"
@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 } from '~/composables/graphql/public/exchange-generated'
import { GetNodeDocument, NearestOffersDocument, QuoteCalculationsDocument, type QuoteCalculationsQueryResult } from '~/composables/graphql/public/geo-generated'
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
type QuoteCalculation = NonNullable<NonNullable<QuoteCalculationsQueryResult['quoteCalculations']>[number]>
type QuoteOffer = NonNullable<NonNullable<QuoteCalculation['offers']>[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 (showQuoteResults.value) return []
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 })
// If a filter locks a view type, switch away from that view
watch([hubId, supplierId], ([newHubId, newSupplierId]) => {
if (newHubId && mapViewMode.value === 'hubs') {
setMapViewMode('offers')
}
if (newSupplierId && mapViewMode.value === 'suppliers') {
setMapViewMode('offers')
}
}, { 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 infoRelatedPoints = 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
})
// Related points for Quote mode (shown on map)
const searchHubPoint = ref<MapItemWithCoords | null>(null)
const searchOfferPoints = computed(() =>
offers.value
.filter((offer) => offer.latitude != null && offer.longitude != null)
.map((offer) => ({
uuid: offer.uuid,
name: offer.productName || '',
latitude: Number(offer.latitude),
longitude: Number(offer.longitude),
type: 'offer' as const
}))
)
const searchRelatedPoints = computed(() => {
const points: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}> = []
if (searchHubPoint.value) {
points.push({
uuid: searchHubPoint.value.uuid,
name: searchHubPoint.value.name,
latitude: searchHubPoint.value.latitude,
longitude: searchHubPoint.value.longitude,
type: 'hub'
})
}
searchOfferPoints.value.forEach((point) => points.push(point))
return points
})
const relatedPoints = computed(() => {
if (infoId.value) return infoRelatedPoints.value
if (showQuoteResults.value) return searchRelatedPoints.value
return []
})
// Offers data for quote results
const offers = ref<QuoteOffer[]>([])
const quoteCalculations = ref<QuoteCalculation[]>([])
const buildCalculationsFromOffers = (list: QuoteOffer[]) =>
list.map((offer) => ({ offers: [offer] })) as QuoteCalculation[]
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
)
const mapInfoLoading = computed(() =>
isInfoLoading.value || (showQuoteResults.value && offersLoading.value)
)
const forceInfoMode = computed(() => showQuoteResults.value)
const hideViewToggle = computed(() => showQuoteResults.value)
const showOffersToggle = computed(() => true)
const showHubsToggle = computed(() => !hubId.value)
const showSuppliersToggle = computed(() => !supplierId.value)
const clusterProductUuid = computed(() => productId.value || undefined)
const clusterHubUuid = computed(() => hubId.value || undefined)
const clusterSupplierUuid = computed(() => supplierId.value || undefined)
// When a product filter is active and we're viewing hubs, use the same list data on the map
// to avoid mismatch between graph-filtered list and clustered map results.
const useServerClustering = computed(() => {
if (productId.value && (mapViewMode.value === 'hubs' || mapViewMode.value === 'suppliers')) return false
return true
})
// 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 = []
quoteCalculations.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 = []
quoteCalculations.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 = []
quoteCalculations.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: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }) => {
if (!item.uuid) return
const label = item.name || item.uuid.slice(0, 8) + '...'
selectItem(type, item.uuid, label)
}
// Close panel (cancel select mode)
const onClosePanel = () => {
cancelSelect()
}
// Handle Info panel events
const onInfoClose = () => {
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
searchHubPoint.value = null
try {
// Prefer geo-based offers with routes when hub + product are selected
if (hubId.value && productId.value) {
const hubData = await execute(GetNodeDocument, { uuid: hubId.value }, 'public', 'geo')
const hub = hubData?.node
if (hub?.latitude != null && hub?.longitude != null) {
searchHubPoint.value = {
uuid: hub.uuid,
name: hub.name || hub.uuid,
latitude: Number(hub.latitude),
longitude: Number(hub.longitude)
}
try {
const calcData = await execute(
QuoteCalculationsDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productId.value,
hubUuid: hubId.value,
quantity: quantity.value ? Number(quantity.value) : null,
limit: 10
},
'public',
'geo'
)
let calculations = (calcData?.quoteCalculations || []).filter((c): c is QuoteCalculation => c !== null)
if (supplierId.value) {
calculations = calculations.map((calc) => ({
...calc,
offers: (calc.offers || []).filter((offer): offer is QuoteOffer => offer !== null).filter(offer => offer.supplierUuid === supplierId.value)
})).filter(calc => calc.offers.length > 0)
}
quoteCalculations.value = calculations
offers.value = calculations.flatMap(calc => (calc.offers || []).filter((offer): offer is QuoteOffer => offer !== null))
} catch (error) {
const geoData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productId.value,
hubUuid: hubId.value,
limit: 12
},
'public',
'geo'
)
let nearest = (geoData?.nearestOffers || []).filter((o): o is QuoteOffer => o !== null)
if (supplierId.value) {
nearest = nearest.filter(o => o?.supplierUuid === supplierId.value)
}
offers.value = nearest
quoteCalculations.value = buildCalculationsFromOffers(nearest)
}
const first = offers.value[0]
if (first?.productName) {
setLabel('product', productId.value, first.productName)
}
} else {
offers.value = []
quoteCalculations.value = []
}
} else {
searchHubPoint.value = null
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')
const exchangeOffers = (data?.getOffers || []).filter((o): o is NonNullable<typeof o> => o !== null)
offers.value = exchangeOffers.map((offer) => ({
uuid: offer.uuid,
productUuid: offer.productUuid,
productName: offer.productName,
teamUuid: offer.teamUuid,
quantity: offer.quantity,
unit: offer.unit,
pricePerUnit: offer.pricePerUnit,
currency: offer.currency,
locationName: offer.locationName,
locationCountry: offer.locationCountry
}))
quoteCalculations.value = buildCalculationsFromOffers(offers.value)
// 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)
}
}
}
} 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>