diff --git a/app/composables/useCatalogHubs.ts b/app/composables/useCatalogHubs.ts index 02393e7..c9306ac 100644 --- a/app/composables/useCatalogHubs.ts +++ b/app/composables/useCatalogHubs.ts @@ -1,9 +1,14 @@ +import type { HubsListQueryResult, NearestHubsQueryResult } from '~/composables/graphql/public/geo-generated' import { HubsListDocument, GetHubCountriesDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated' const PAGE_SIZE = 24 +// Type from codegen +type HubItem = NonNullable[number]> +type NearestHubItem = NonNullable[number]> + // Shared state across list and map views -const items = ref([]) +const items = ref>([]) const total = ref(0) const selectedFilter = ref('all') const selectedCountry = ref('all') @@ -67,7 +72,7 @@ export function useCatalogHubs() { 'public', 'geo' ) - const next = data?.nearestHubs || [] + const next = (data?.nearestHubs || []).filter((h): h is NearestHubItem => h !== null) items.value = next total.value = next.length isInitialized.value = true @@ -95,7 +100,7 @@ export function useCatalogHubs() { 'public', 'geo' ) - const next = data?.hubsList || [] + const next = (data?.hubsList || []).filter((h): h is HubItem => h !== null) items.value = replace ? next : items.value.concat(next) // hubsList doesn't return total count, estimate from fetched items if (replace) { diff --git a/app/composables/useCatalogInfo.ts b/app/composables/useCatalogInfo.ts index 06262c2..70fd96a 100644 --- a/app/composables/useCatalogInfo.ts +++ b/app/composables/useCatalogInfo.ts @@ -1,24 +1,82 @@ import type { InfoEntityType } from './useCatalogSearch' +import type { + GetNodeQueryResult, + NearestHubsQueryResult, + NearestOffersQueryResult +} from '~/composables/graphql/public/geo-generated' import { GetNodeDocument, NearestOffersDocument, NearestHubsDocument } from '~/composables/graphql/public/geo-generated' +import type { + GetOfferQueryResult, + GetSupplierProfileQueryResult +} from '~/composables/graphql/public/exchange-generated' import { GetOfferDocument, GetSupplierProfileDocument } from '~/composables/graphql/public/exchange-generated' +// Types from codegen +type NodeEntity = NonNullable +type OfferEntity = NonNullable +type SupplierProfile = NonNullable +type HubItem = NonNullable[number]> +type OfferItem = NonNullable[number]> + +// Product type (aggregated from offers) +interface ProductItem { + uuid: string + name: string + offersCount?: number +} + +// Extended entity type with optional supplierName +// Using intersection to allow both coordinate patterns (node vs offer) +interface InfoEntity { + uuid?: string | null + name?: string | null + // Node coordinates + latitude?: number | null + longitude?: number | null + // Offer coordinates (different field names) + locationLatitude?: number | null + locationLongitude?: number | null + locationUuid?: string + locationName?: string + // Offer fields + productUuid?: string + productName?: string + teamUuid?: string + // Enriched field + supplierName?: string + // Allow any other fields + [key: string]: unknown +} + +// Helper to get coordinates from entity (handles both node and offer patterns) +function getEntityCoords(e: InfoEntity | null): { lat: number; lon: number } | null { + if (!e) return null + // Try offer coords first (locationLatitude/locationLongitude) + const lat = e.locationLatitude ?? e.latitude + const lon = e.locationLongitude ?? e.longitude + if (lat != null && lon != null) { + return { lat, lon } + } + return null +} + export function useCatalogInfo() { const { execute } = useGraphQL() - // State - const entity = ref(null) - const entityType = ref(null) // Track entity type explicitly - const relatedProducts = ref([]) - const relatedHubs = ref([]) - const relatedSuppliers = ref([]) - const relatedOffers = ref([]) + // State with proper types + const entity = ref(null) + const entityType = 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) @@ -34,9 +92,10 @@ export function useCatalogInfo() { try { // Load hub node details const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') - entity.value = nodeData?.node + entity.value = nodeData?.node ?? null - if (!entity.value?.latitude || !entity.value?.longitude) { + const coords = getEntityCoords(entity.value) + if (!coords) { console.warn('Hub has no coordinates') return } @@ -52,40 +111,41 @@ export function useCatalogInfo() { execute( NearestOffersDocument, { - lat: entity.value.latitude, - lon: entity.value.longitude, + lat: coords.lat, + lon: coords.lon, radius: 500 }, 'public', 'geo' ).then(offersData => { // Group offers by product - const productsMap = new Map() - const suppliersMap = new Map() + const productsMap = new Map() + const suppliersMap = new Map() - offersData?.nearestOffers?.forEach((offer: any) => { + offersData?.nearestOffers?.forEach(offer => { + if (!offer) return // Products - if (offer?.productUuid) { - if (!productsMap.has(offer.productUuid)) { + if (offer.productUuid && offer.productName) { + const existing = productsMap.get(offer.productUuid) + if (existing) { + existing.offersCount = (existing.offersCount || 0) + 1 + } else { productsMap.set(offer.productUuid, { uuid: offer.productUuid, name: offer.productName, - offersCount: 0 + offersCount: 1 }) } - productsMap.get(offer.productUuid)!.offersCount++ } // Suppliers (extract from offers) - if (offer?.supplierUuid) { - if (!suppliersMap.has(offer.supplierUuid)) { - suppliersMap.set(offer.supplierUuid, { - uuid: offer.supplierUuid, - name: offer.supplierName || 'Supplier', - latitude: offer.latitude, - longitude: offer.longitude - }) - } + if (offer.supplierUuid && !suppliersMap.has(offer.supplierUuid)) { + suppliersMap.set(offer.supplierUuid, { + uuid: offer.supplierUuid, + name: offer.supplierName || 'Supplier', + latitude: offer.latitude, + longitude: offer.longitude + }) } }) @@ -101,7 +161,7 @@ export function useCatalogInfo() { .catch(() => suppliersMap.get(supplierId)) // Fallback to basic info ) ).then(profiles => { - relatedSuppliers.value = profiles.filter(Boolean) + relatedSuppliers.value = profiles.filter((p): p is SupplierProfile => p != null) isLoadingSuppliers.value = false }) } else { @@ -123,7 +183,7 @@ export function useCatalogInfo() { try { // Load supplier node details (might be geo node) const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo') - entity.value = nodeData?.node + entity.value = nodeData?.node ?? null // Also try to get supplier profile from exchange API for additional details try { @@ -164,17 +224,18 @@ export function useCatalogInfo() { 'geo' ).then(offersData => { // Group offers by product - const productsMap = new Map() - offersData?.nearestOffers?.forEach((offer: any) => { - if (offer?.productUuid) { - if (!productsMap.has(offer.productUuid)) { - productsMap.set(offer.productUuid, { - uuid: offer.productUuid, - name: offer.productName, - offersCount: 0 - }) - } - productsMap.get(offer.productUuid)!.offersCount++ + const productsMap = new Map() + offersData?.nearestOffers?.forEach(offer => { + if (!offer || !offer.productUuid || !offer.productName) return + const existing = productsMap.get(offer.productUuid) + if (existing) { + existing.offersCount = (existing.offersCount || 0) + 1 + } else { + productsMap.set(offer.productUuid, { + uuid: offer.productUuid, + name: offer.productName, + offersCount: 1 + }) } }) relatedProducts.value = Array.from(productsMap.values()) @@ -194,7 +255,7 @@ export function useCatalogInfo() { 'public', 'geo' ).then(hubsData => { - relatedHubs.value = hubsData?.nearestHubs || [] + relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null) }).finally(() => { isLoadingHubs.value = false }) @@ -210,9 +271,10 @@ export function useCatalogInfo() { try { // Load offer details from exchange API const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange') - entity.value = offerData?.getOffer + entity.value = offerData?.getOffer ?? null - if (!entity.value?.latitude || !entity.value?.longitude) { + const coords = getEntityCoords(entity.value) + if (!coords) { console.warn('Offer has no coordinates') return } @@ -235,15 +297,15 @@ export function useCatalogInfo() { execute( NearestHubsDocument, { - lat: entity.value.latitude, - lon: entity.value.longitude, + lat: coords.lat, + lon: coords.lon, radius: 1000, limit: 12 }, 'public', 'geo' ).then(hubsData => { - relatedHubs.value = hubsData?.nearestHubs || [] + relatedHubs.value = (hubsData?.nearestHubs || []).filter((h): h is HubItem => h !== null) }).finally(() => { isLoadingHubs.value = false }) @@ -257,9 +319,12 @@ export function useCatalogInfo() { 'public', 'exchange' ).then(supplierData => { - relatedSuppliers.value = supplierData?.getSupplierProfile - ? [supplierData.getSupplierProfile] - : [] + const supplier = supplierData?.getSupplierProfile + relatedSuppliers.value = supplier ? [supplier] : [] + // Enrich entity with supplier name for display + if (supplier?.name && entity.value) { + entity.value = { ...entity.value, supplierName: supplier.name } + } }).catch(() => { // Supplier might not exist }).finally(() => { @@ -301,19 +366,19 @@ export function useCatalogInfo() { ) // Offers already include routes from backend - relatedOffers.value = offersData?.nearestOffers || [] + relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null) isLoadingOffers.value = false // Extract unique suppliers from offers (use supplierUuid from offers) const supplierUuids = new Set() - relatedOffers.value.forEach((offer: any) => { + relatedOffers.value.forEach(offer => { if (offer.supplierUuid) { supplierUuids.add(offer.supplierUuid) } }) // Load supplier profiles (limit to 12) - const suppliers: any[] = [] + const suppliers: SupplierProfile[] = [] for (const uuid of Array.from(supplierUuids).slice(0, 12)) { try { const supplierData = await execute( @@ -366,11 +431,11 @@ export function useCatalogInfo() { 'geo' ) - relatedOffers.value = offersData?.nearestOffers || [] + relatedOffers.value = (offersData?.nearestOffers || []).filter((o): o is OfferItem => o !== null) isLoadingOffers.value = false // Load hubs near each offer and aggregate (limit to 12) - const allHubs = new Map() + const allHubs = new Map() for (const offer of relatedOffers.value.slice(0, 3)) { // Check first 3 offers if (!offer.latitude || !offer.longitude) continue @@ -387,8 +452,8 @@ export function useCatalogInfo() { 'public', 'geo' ) - hubsData?.nearestHubs?.forEach((hub: any) => { - if (!allHubs.has(hub.uuid)) { + hubsData?.nearestHubs?.forEach(hub => { + if (hub && hub.uuid && !allHubs.has(hub.uuid)) { allHubs.set(hub.uuid, hub) } }) @@ -415,10 +480,10 @@ export function useCatalogInfo() { if (!entity.value) return // Use stored entity type instead of inferring from properties - if (entityType.value === 'hub') { + if (entityType.value === 'hub' && entity.value.uuid) { await loadOffersForHub(entity.value.uuid, productUuid) activeTab.value = 'offers' - } else if (entityType.value === 'supplier') { + } else if (entityType.value === 'supplier' && entity.value.uuid) { await loadOffersForSupplier(entity.value.uuid, productUuid) activeTab.value = 'offers' } diff --git a/app/composables/useCatalogOffers.ts b/app/composables/useCatalogOffers.ts index 95b5721..9a7943b 100644 --- a/app/composables/useCatalogOffers.ts +++ b/app/composables/useCatalogOffers.ts @@ -1,9 +1,13 @@ +import type { GetOffersQueryResult } from '~/composables/graphql/public/exchange-generated' import { GetOffersDocument } from '~/composables/graphql/public/exchange-generated' const PAGE_SIZE = 24 +// Type from codegen +type OfferItem = NonNullable[number]> + // Shared state across list and map views -const items = ref([]) +const items = ref([]) const total = ref(0) const selectedProductUuid = ref(null) const isLoading = ref(false) @@ -18,7 +22,7 @@ export function useCatalogOffers() { .filter(offer => offer.locationLatitude && offer.locationLongitude) .map(offer => ({ uuid: offer.uuid, - name: offer.productName || offer.title, + name: offer.productName || offer.locationName, latitude: offer.locationLatitude, longitude: offer.locationLongitude, country: offer.locationCountry @@ -40,7 +44,7 @@ export function useCatalogOffers() { 'public', 'exchange' ) - const next = data?.getOffers || [] + const next = (data?.getOffers || []).filter((o): o is OfferItem => o !== null) items.value = replace ? next : items.value.concat(next) total.value = data?.getOffersCount ?? total.value isInitialized.value = true diff --git a/app/composables/useCatalogProducts.ts b/app/composables/useCatalogProducts.ts index 87e35f2..a38cd4d 100644 --- a/app/composables/useCatalogProducts.ts +++ b/app/composables/useCatalogProducts.ts @@ -1,3 +1,4 @@ +import type { ProductsListQueryResult } from '~/composables/graphql/public/geo-generated' import { ProductsListDocument, GetNodeDocument, @@ -7,8 +8,11 @@ import { GetSupplierProfileDocument } from '~/composables/graphql/public/exchange-generated' +// Type from codegen +type ProductItem = NonNullable[number]> + // Shared state -const items = ref([]) +const items = ref([]) const isLoading = ref(false) const isLoadingMore = ref(false) const isInitialized = ref(false) @@ -130,7 +134,7 @@ export function useCatalogProducts() { 'public', 'geo' ) - items.value = data?.productsList || [] + items.value = (data?.productsList || []).filter((p): p is ProductItem => p !== null) } isInitialized.value = true diff --git a/app/composables/useCatalogSearch.ts b/app/composables/useCatalogSearch.ts index 1e86674..c0c9173 100644 --- a/app/composables/useCatalogSearch.ts +++ b/app/composables/useCatalogSearch.ts @@ -85,12 +85,12 @@ export function useCatalogSearch() { const quantity = computed(() => route.query.qty as string | undefined) // Map bounds from URL (format: west,south,east,north) - const urlBounds = computed(() => { + const urlBounds = computed((): { west: number; south: number; east: number; north: number } | null => { const b = route.query.bounds as string | undefined if (!b) return null const parts = b.split(',').map(Number) if (parts.length !== 4 || parts.some(isNaN)) return null - return { west: parts[0], south: parts[1], east: parts[2], north: parts[3] } + return { west: parts[0]!, south: parts[1]!, east: parts[2]!, north: parts[3]! } }) // Filter by bounds checkbox state from URL diff --git a/app/composables/useCatalogSuppliers.ts b/app/composables/useCatalogSuppliers.ts index 0836c8a..1c1c9ba 100644 --- a/app/composables/useCatalogSuppliers.ts +++ b/app/composables/useCatalogSuppliers.ts @@ -1,9 +1,14 @@ +import type { SuppliersListQueryResult, NearestSuppliersQueryResult } from '~/composables/graphql/public/geo-generated' import { SuppliersListDocument, NearestSuppliersDocument } from '~/composables/graphql/public/geo-generated' const PAGE_SIZE = 24 +// Types from codegen +type SupplierItem = NonNullable[number]> +type NearestSupplierItem = NonNullable[number]> + // Shared state across list and map views -const items = ref([]) +const items = ref>([]) const total = ref(0) const isLoading = ref(false) const isLoadingMore = ref(false) @@ -15,7 +20,7 @@ export function useCatalogSuppliers() { const { execute } = useGraphQL() const itemsWithCoords = computed(() => - items.value.filter(s => s.latitude && s.longitude) + items.value.filter((s): s is NearestSupplierItem => 'latitude' in s && 'longitude' in s && s.latitude != null && s.longitude != null) ) const canLoadMore = computed(() => items.value.length < total.value) @@ -38,7 +43,7 @@ export function useCatalogSuppliers() { 'public', 'geo' ) - items.value = data?.nearestSuppliers || [] + items.value = (data?.nearestSuppliers || []).filter((s): s is NearestSupplierItem => s !== null) total.value = items.value.length isInitialized.value = true return @@ -60,7 +65,7 @@ export function useCatalogSuppliers() { 'public', 'geo' ) - const next = data?.suppliersList || [] + const next = (data?.suppliersList || []).filter((s): s is SupplierItem => s !== null) items.value = replace ? next : items.value.concat(next) // suppliersList doesn't return total count, estimate from fetched items diff --git a/app/pages/catalog/index.vue b/app/pages/catalog/index.vue index d263593..5224707 100644 --- a/app/pages/catalog/index.vue +++ b/app/pages/catalog/index.vue @@ -91,11 +91,27 @@ 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 = ( + 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(() => { - if (selectMode.value === 'product') return filteredProducts.value - if (selectMode.value === 'hub') return filteredHubs.value - if (selectMode.value === 'supplier') return filteredSuppliers.value +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 [] }) @@ -280,10 +296,10 @@ const relatedPoints = computed(() => { // Add all hubs relatedHubs.value.forEach(hub => { - if (hub.latitude && hub.longitude) { + if (hub.uuid && hub.latitude && hub.longitude) { points.push({ uuid: hub.uuid, - name: hub.name, + name: hub.name || '', latitude: hub.latitude, longitude: hub.longitude, type: 'hub' @@ -293,10 +309,10 @@ const relatedPoints = computed(() => { // Add all suppliers relatedSuppliers.value.forEach(supplier => { - if (supplier.latitude && supplier.longitude) { + if (supplier.uuid && supplier.latitude && supplier.longitude) { points.push({ uuid: supplier.uuid, - name: supplier.name, + name: supplier.name || '', latitude: supplier.latitude, longitude: supplier.longitude, type: 'supplier' @@ -416,10 +432,17 @@ 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) + // 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) + '...'