diff --git a/app/components/catalog/CatalogMap.vue b/app/components/catalog/CatalogMap.vue index 42f7977..2f27c38 100644 --- a/app/components/catalog/CatalogMap.vue +++ b/app/components/catalog/CatalogMap.vue @@ -51,6 +51,13 @@ const props = withDefaults(defineProps<{ entityType?: 'offer' | 'hub' | 'supplier' initialCenter?: [number, number] initialZoom?: number + relatedPoints?: Array<{ + uuid: string + name: string + latitude: number + longitude: number + type: 'hub' | 'supplier' | 'offer' + }> }>(), { pointColor: '#f97316', entityType: 'offer', @@ -58,7 +65,8 @@ const props = withDefaults(defineProps<{ initialZoom: 2, useServerClustering: false, items: () => [], - clusteredPoints: () => [] + clusteredPoints: () => [], + relatedPoints: () => [] }) const emit = defineEmits<{ @@ -170,8 +178,32 @@ const hoveredPointGeoJson = computed(() => ({ }] : [] })) +// Related points GeoJSON (for Info mode) +const relatedPointsGeoJson = computed(() => { + if (!props.relatedPoints || props.relatedPoints.length === 0) { + return { type: 'FeatureCollection' as const, features: [] } + } + + return { + type: 'FeatureCollection' as const, + features: props.relatedPoints.map(point => ({ + type: 'Feature' as const, + properties: { + uuid: point.uuid, + name: point.name, + type: point.type + }, + geometry: { + type: 'Point' as const, + coordinates: [point.longitude, point.latitude] + } + })) + } +}) + const sourceId = computed(() => `${props.mapId}-points`) const hoveredSourceId = computed(() => `${props.mapId}-hovered`) +const relatedSourceId = computed(() => `${props.mapId}-related`) const emitBoundsChange = (map: MapboxMapType) => { const bounds = map.getBounds() @@ -338,6 +370,55 @@ const initClientClusteringLayers = async (map: MapboxMapType) => { } }) + // Related points layer (for Info mode - smaller cyan circles) + map.addSource(relatedSourceId.value, { + type: 'geojson', + data: relatedPointsGeoJson.value + }) + map.addLayer({ + id: `${props.mapId}-related-circles`, + type: 'circle', + source: relatedSourceId.value, + paint: { + 'circle-radius': 8, + 'circle-color': '#06b6d4', // cyan + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + }) + map.addLayer({ + id: `${props.mapId}-related-labels`, + type: 'symbol', + source: relatedSourceId.value, + layout: { + 'text-field': ['get', 'name'], + 'text-size': 11, + 'text-anchor': 'top', + 'text-offset': [0, 1.2] + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': '#000000', + 'text-halo-width': 1 + } + }) + + // Click handlers for related points + map.on('click', `${props.mapId}-related-circles`, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] }) + if (!features.length) return + const props_data = features[0].properties + emit('select-item', props_data?.uuid, props_data) + }) + + map.on('mouseenter', `${props.mapId}-related-circles`, () => { + map.getCanvas().style.cursor = 'pointer' + }) + + map.on('mouseleave', `${props.mapId}-related-circles`, () => { + map.getCanvas().style.cursor = '' + }) + // Auto-fit bounds to all items if (!didFitBounds.value && props.items.length > 0) { const bounds = new LngLatBounds() @@ -470,6 +551,55 @@ const initServerClusteringLayers = async (map: MapboxMapType) => { 'circle-stroke-color': '#ffffff' } }) + + // Related points layer (for Info mode - smaller cyan circles) + map.addSource(relatedSourceId.value, { + type: 'geojson', + data: relatedPointsGeoJson.value + }) + map.addLayer({ + id: `${props.mapId}-related-circles`, + type: 'circle', + source: relatedSourceId.value, + paint: { + 'circle-radius': 8, + 'circle-color': '#06b6d4', // cyan + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + }) + map.addLayer({ + id: `${props.mapId}-related-labels`, + type: 'symbol', + source: relatedSourceId.value, + layout: { + 'text-field': ['get', 'name'], + 'text-size': 11, + 'text-anchor': 'top', + 'text-offset': [0, 1.2] + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': '#000000', + 'text-halo-width': 1 + } + }) + + // Click handlers for related points + map.on('click', `${props.mapId}-related-circles`, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] }) + if (!features.length) return + const props_data = features[0].properties + emit('select-item', props_data?.uuid, props_data) + }) + + map.on('mouseenter', `${props.mapId}-related-circles`, () => { + map.getCanvas().style.cursor = 'pointer' + }) + + map.on('mouseleave', `${props.mapId}-related-circles`, () => { + map.getCanvas().style.cursor = '' + }) } // Update map data when items or clusteredPoints change @@ -490,6 +620,15 @@ watch(() => props.hoveredItem, () => { } }, { deep: true }) +// Update related points layer when relatedPoints changes +watch(() => props.relatedPoints, () => { + if (!mapRef.value || !mapInitialized.value) return + const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined + if (source) { + source.setData(relatedPointsGeoJson.value) + } +}, { deep: true }) + // Watch for pointColor or entityType changes - update colors and icons watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => { if (!mapRef.value || !mapInitialized.value) return diff --git a/app/components/catalog/InfoPanel.vue b/app/components/catalog/InfoPanel.vue new file mode 100644 index 0000000..90068df --- /dev/null +++ b/app/components/catalog/InfoPanel.vue @@ -0,0 +1,277 @@ + + + diff --git a/app/components/page/CatalogPage.vue b/app/components/page/CatalogPage.vue index bd841f9..6eedabd 100644 --- a/app/components/page/CatalogPage.vue +++ b/app/components/page/CatalogPage.vue @@ -23,6 +23,7 @@ :entity-type="activeEntityType" :hovered-item-id="hoveredId" :hovered-item="hoveredItem" + :related-points="relatedPoints" @select-item="onMapSelect" @bounds-change="onBoundsChange" /> @@ -210,6 +211,13 @@ const props = withDefaults(defineProps<{ items?: MapItem[] showPanel?: boolean filterByBounds?: boolean + relatedPoints?: Array<{ + uuid: string + name: string + latitude: number + longitude: number + type: 'hub' | 'supplier' | 'offer' + }> }>(), { loading: false, useServerClustering: true, @@ -218,7 +226,8 @@ const props = withDefaults(defineProps<{ pointColor: '#f97316', items: () => [], showPanel: false, - filterByBounds: false + filterByBounds: false, + relatedPoints: () => [] }) const emit = defineEmits<{ diff --git a/app/composables/useCatalogInfo.ts b/app/composables/useCatalogInfo.ts new file mode 100644 index 0000000..90fcdd2 --- /dev/null +++ b/app/composables/useCatalogInfo.ts @@ -0,0 +1,296 @@ +import type { InfoEntityType } from './useCatalogSearch' +import { + GetNodeDocument, + GetProductsNearHubDocument, + GetProductsBySupplierDocument, + GetHubsNearOfferDocument, + GetOffersByHubDocument, + GetOffersBySupplierProductDocument +} from '~/composables/graphql/public/geo-generated' +import { + GetOfferDocument, + GetSupplierProfileDocument +} from '~/composables/graphql/public/exchange-generated' + +export function useCatalogInfo() { + const { execute } = useGraphQL() + + // State + const entity = ref(null) + const relatedProducts = ref([]) + const relatedHubs = ref([]) + const relatedSuppliers = ref([]) + const relatedOffers = ref([]) + const selectedProduct = ref(null) + const activeTab = ref('products') + const isLoading = ref(false) + + // Load hub info: hub details + products + suppliers + const loadHubInfo = async (uuid: string) => { + try { + // Load hub node details + const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') + entity.value = nodeData?.node + + // Load products near hub + const productsData = await execute( + GetProductsNearHubDocument, + { hubUuid: uuid, radiusKm: 500 }, + 'public', + 'geo' + ) + relatedProducts.value = productsData?.productsNearHub || [] + + // Set default active tab to products + activeTab.value = 'products' + + // Note: Suppliers loaded after product selection via loadOffersForHub + } catch (error) { + console.error('Error loading hub info:', error) + } + } + + // Load supplier info: supplier details + products + const loadSupplierInfo = async (uuid: string) => { + try { + // Load supplier node details + const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') + entity.value = nodeData?.node + + // Also try to get supplier profile from exchange API for additional details + try { + const profileData = await execute( + GetSupplierProfileDocument, + { supplierUuid: uuid }, + 'public', + 'exchange' + ) + if (profileData?.getSupplierProfile) { + entity.value = { ...entity.value, ...profileData.getSupplierProfile } + } + } catch (e) { + // Supplier profile might not exist, ignore + } + + // Load products from supplier + const productsData = await execute( + GetProductsBySupplierDocument, + { supplierUuid: uuid }, + 'public', + 'geo' + ) + relatedProducts.value = productsData?.productsBySupplier || [] + + // Set default active tab to products + activeTab.value = 'products' + + // Note: Hubs will be loaded after product selection + } catch (error) { + console.error('Error loading supplier info:', error) + } + } + + // Load offer info: offer details + supplier + hubs + const loadOfferInfo = async (uuid: string) => { + try { + // Load offer details from exchange API + const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange') + entity.value = offerData?.getOffer + + // Load hubs near offer + const hubsData = await execute( + GetHubsNearOfferDocument, + { offerUuid: uuid, limit: 12 }, + 'public', + 'geo' + ) + relatedHubs.value = hubsData?.hubsNearOffer || [] + + // If offer has supplier UUID, load supplier profile + if (entity.value?.teamUuid) { + try { + const supplierData = await execute( + GetSupplierProfileDocument, + { supplierUuid: entity.value.teamUuid }, + 'public', + 'exchange' + ) + relatedSuppliers.value = supplierData?.getSupplierProfile + ? [supplierData.getSupplierProfile] + : [] + } catch (e) { + // Supplier might not exist + } + } + + // Set default active tab to hubs + activeTab.value = 'hubs' + + // Set product as "related product" (single item) + if (entity.value?.productUuid && entity.value?.productName) { + relatedProducts.value = [ + { + uuid: entity.value.productUuid, + name: entity.value.productName + } + ] + } + } catch (error) { + console.error('Error loading offer info:', error) + } + } + + // Load offers for hub after product selection + const loadOffersForHub = async (hubUuid: string, productUuid: string) => { + try { + const offersData = await execute( + GetOffersByHubDocument, + { hubUuid, productUuid, limit: 12 }, + 'public', + 'geo' + ) + relatedOffers.value = offersData?.offersByHub || [] + + // Extract unique suppliers from offers + const supplierUuids = new Set() + relatedOffers.value.forEach((offer: any) => { + if (offer.teamUuid) { + supplierUuids.add(offer.teamUuid) + } + }) + + // Load supplier profiles (limit to 12) + const suppliers: any[] = [] + for (const supplierUuid of Array.from(supplierUuids).slice(0, 12)) { + try { + const supplierData = await execute( + GetSupplierProfileDocument, + { supplierUuid }, + 'public', + 'exchange' + ) + if (supplierData?.getSupplierProfile) { + suppliers.push(supplierData.getSupplierProfile) + } + } catch (e) { + // Supplier might not exist + } + } + relatedSuppliers.value = suppliers + } catch (error) { + console.error('Error loading offers for hub:', error) + } + } + + // Load offers for supplier after product selection + const loadOffersForSupplier = async (supplierUuid: string, productUuid: string) => { + try { + const offersData = await execute( + GetOffersBySupplierProductDocument, + { supplierUuid, productUuid }, + 'public', + 'geo' + ) + relatedOffers.value = offersData?.offersBySupplierProduct || [] + + // Load hubs for each offer and aggregate (limit to 12) + const allHubs = new Map() + for (const offer of relatedOffers.value.slice(0, 3)) { + // Check first 3 offers + try { + const hubsData = await execute( + GetHubsNearOfferDocument, + { offerUuid: offer.uuid, limit: 5 }, + 'public', + 'geo' + ) + hubsData?.hubsNearOffer?.forEach((hub: any) => { + if (!allHubs.has(hub.uuid)) { + allHubs.set(hub.uuid, hub) + } + }) + } catch (e) { + // Hubs might not exist for this offer + } + + if (allHubs.size >= 12) break + } + relatedHubs.value = Array.from(allHubs.values()).slice(0, 12) + } catch (error) { + console.error('Error loading offers for supplier:', error) + } + } + + // Select product (triggers offers loading) + const selectProduct = async (productUuid: string) => { + selectedProduct.value = productUuid + + if (!entity.value) return + + const entityType = entity.value.uuid + if (!entityType) return + + // Load offers based on entity type + if (entity.value.transportTypes) { + // This is a hub (has transportTypes) + await loadOffersForHub(entity.value.uuid, productUuid) + activeTab.value = 'offers' + } else if (entity.value.teamUuid || entity.value.onTimeRate !== undefined) { + // This is a supplier + await loadOffersForSupplier(entity.value.uuid, productUuid) + activeTab.value = 'offers' + } + } + + // Set active tab + const setActiveTab = (tab: string) => { + activeTab.value = tab + } + + // Main load method - dispatches to specific loaders + const loadInfo = async (type: InfoEntityType, uuid: string) => { + isLoading.value = true + clearInfo() // Clear previous data + + try { + if (type === 'hub') { + await loadHubInfo(uuid) + } else if (type === 'supplier') { + await loadSupplierInfo(uuid) + } else if (type === 'offer') { + await loadOfferInfo(uuid) + } + } finally { + isLoading.value = false + } + } + + // Clear all info data + const clearInfo = () => { + entity.value = null + relatedProducts.value = [] + relatedHubs.value = [] + relatedSuppliers.value = [] + relatedOffers.value = [] + selectedProduct.value = null + activeTab.value = 'products' + } + + return { + // State + entity, + relatedProducts, + relatedHubs, + relatedSuppliers, + relatedOffers, + selectedProduct, + activeTab, + isLoading, + + // Actions + loadInfo, + selectProduct, + setActiveTab, + clearInfo + } +} diff --git a/app/composables/useCatalogSearch.ts b/app/composables/useCatalogSearch.ts index a2502c7..3030225 100644 --- a/app/composables/useCatalogSearch.ts +++ b/app/composables/useCatalogSearch.ts @@ -3,6 +3,7 @@ import type { LocationQuery } from 'vue-router' export type SelectMode = 'product' | 'supplier' | 'hub' | null export type MapViewMode = 'offers' | 'hubs' | 'suppliers' export type CatalogMode = 'explore' | 'quote' +export type InfoEntityType = 'hub' | 'supplier' | 'offer' export type DisplayMode = | 'map-default' | 'grid-products' @@ -13,6 +14,11 @@ export type DisplayMode = | 'grid-products-in-hub' | 'grid-offers' +export interface InfoId { + type: InfoEntityType + uuid: string +} + export interface SearchFilter { type: 'product' | 'supplier' | 'hub' | 'quantity' id: string @@ -56,6 +62,17 @@ export function useCatalogSearch() { return null }) + // Parse info state from query param (format: "type:uuid") + const infoId = computed(() => { + const info = route.query.info as string | undefined + if (!info) return null + const [type, uuid] = info.split(':') + if (['hub', 'supplier', 'offer'].includes(type) && uuid) { + return { type: type as InfoEntityType, uuid } + } + return null + }) + const productId = computed(() => route.query.product as string | undefined) const supplierId = computed(() => route.query.supplier as string | undefined) const hubId = computed(() => route.query.hub as string | undefined) @@ -210,6 +227,14 @@ export function useCatalogSearch() { updateQuery({ qty }) } + const openInfo = (type: InfoEntityType, uuid: string) => { + updateQuery({ info: `${type}:${uuid}`, select: null }) + } + + const closeInfo = () => { + updateQuery({ info: null }) + } + const clearAll = () => { if (isMainPage.value) { router.push({ path: localePath('/catalog'), query: {} }) @@ -256,6 +281,7 @@ export function useCatalogSearch() { return { // State selectMode, + infoId, displayMode, catalogMode, productId, @@ -283,6 +309,8 @@ export function useCatalogSearch() { removeFilter, editFilter, setQuantity, + openInfo, + closeInfo, clearAll, setLabel, setMapViewMode, diff --git a/app/pages/catalog/index.vue b/app/pages/catalog/index.vue index 708d965..36f3d40 100644 --- a/app/pages/catalog/index.vue +++ b/app/pages/catalog/index.vue @@ -10,11 +10,12 @@ :hovered-id="hoveredItemId" :show-panel="showPanel" :filter-by-bounds="filterByBounds" + :related-points="relatedPoints" @select="onMapSelect" @bounds-change="onBoundsChange" @update:filter-by-bounds="filterByBounds = $event" > - +