Fix type safety in catalog composables + 3 InfoPanel bugs
All checks were successful
Build Docker Image / build (push) Successful in 3m42s

- Add proper codegen types to all catalog composables:
  - useCatalogHubs: HubItem, NearestHubItem
  - useCatalogSuppliers: SupplierItem, NearestSupplierItem
  - useCatalogProducts: ProductItem
  - useCatalogOffers: OfferItem
  - useCatalogInfo: InfoEntity, ProductItem, HubItem, OfferItem

- Fix InfoPanel bugs for offers:
  - Use locationLatitude/locationLongitude for offer coordinates
  - Enrich entity with supplierName after loading profile
  - Apply-to-filter now adds both product AND hub for offers

- Filter null values from GraphQL array responses
- Add type-safe coordinate helper (getEntityCoords)
- Fix urlBounds type inference in useCatalogSearch
This commit is contained in:
Ruslan Bakiev
2026-01-26 23:30:16 +07:00
parent 839ab4e830
commit 70c53da8eb
7 changed files with 190 additions and 84 deletions

View File

@@ -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<NonNullable<HubsListQueryResult['hubsList']>[number]>
type NearestHubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
// Shared state across list and map views
const items = ref<any[]>([])
const items = ref<Array<HubItem | NearestHubItem>>([])
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) {

View File

@@ -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<GetNodeQueryResult['node']>
type OfferEntity = NonNullable<GetOfferQueryResult['getOffer']>
type SupplierProfile = NonNullable<GetSupplierProfileQueryResult['getSupplierProfile']>
type HubItem = NonNullable<NonNullable<NearestHubsQueryResult['nearestHubs']>[number]>
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[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<any>(null)
const entityType = ref<InfoEntityType | null>(null) // Track entity type explicitly
const relatedProducts = ref<any[]>([])
const relatedHubs = ref<any[]>([])
const relatedSuppliers = ref<any[]>([])
const relatedOffers = ref<any[]>([])
// State with proper types
const entity = ref<InfoEntity | null>(null)
const entityType = ref<InfoEntityType | null>(null)
const relatedProducts = ref<ProductItem[]>([])
const relatedHubs = ref<HubItem[]>([])
const relatedSuppliers = ref<SupplierProfile[]>([])
const relatedOffers = ref<OfferItem[]>([])
const selectedProduct = ref<string | null>(null)
const activeTab = ref<string>('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<string, any>()
const suppliersMap = new Map<string, any>()
const productsMap = new Map<string, ProductItem>()
const suppliersMap = new Map<string, { uuid: string; name: string; latitude?: number | null; longitude?: number | null }>()
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<string, any>()
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<string, ProductItem>()
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<string>()
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<string, any>()
const allHubs = new Map<string, HubItem>()
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'
}

View File

@@ -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<NonNullable<GetOffersQueryResult['getOffers']>[number]>
// Shared state across list and map views
const items = ref<any[]>([])
const items = ref<OfferItem[]>([])
const total = ref(0)
const selectedProductUuid = ref<string | null>(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

View File

@@ -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<NonNullable<ProductsListQueryResult['productsList']>[number]>
// Shared state
const items = ref<any[]>([])
const items = ref<ProductItem[]>([])
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

View File

@@ -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

View File

@@ -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<NonNullable<SuppliersListQueryResult['suppliersList']>[number]>
type NearestSupplierItem = NonNullable<NonNullable<NearestSuppliersQueryResult['nearestSuppliers']>[number]>
// Shared state across list and map views
const items = ref<any[]>([])
const items = ref<Array<SupplierItem | NearestSupplierItem>>([])
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

View File

@@ -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 = <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(() => {
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) + '...'