Files
webapp/app/composables/useCatalogProducts.ts
Ruslan Bakiev 70c53da8eb
All checks were successful
Build Docker Image / build (push) Successful in 3m42s
Fix type safety in catalog composables + 3 InfoPanel bugs
- 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
2026-01-26 23:30:16 +07:00

218 lines
6.2 KiB
TypeScript

import type { ProductsListQueryResult } from '~/composables/graphql/public/geo-generated'
import {
ProductsListDocument,
GetNodeDocument,
NearestOffersDocument
} from '~/composables/graphql/public/geo-generated'
import {
GetSupplierProfileDocument
} from '~/composables/graphql/public/exchange-generated'
// Type from codegen
type ProductItem = NonNullable<NonNullable<ProductsListQueryResult['productsList']>[number]>
// Shared state
const items = ref<ProductItem[]>([])
const isLoading = ref(false)
const isLoadingMore = ref(false)
const isInitialized = ref(false)
// Filter state
const filterSupplierUuid = ref<string | null>(null)
const filterHubUuid = ref<string | null>(null)
const filterBounds = ref<{ west: number; south: number; east: number; north: number } | null>(null)
export function useCatalogProducts() {
const { execute } = useGraphQL()
// Products don't have server-side pagination yet, so we load all at once
const canLoadMore = computed(() => false)
const fetchProducts = async () => {
if (isLoading.value) return
isLoading.value = true
try {
let data
if (filterSupplierUuid.value) {
// Products from specific supplier - get supplier coordinates first
const supplierData = await execute(
GetSupplierProfileDocument,
{ uuid: filterSupplierUuid.value },
'public',
'exchange'
)
const supplier = supplierData?.getSupplierProfile
if (!supplier?.latitude || !supplier?.longitude) {
console.warn('Supplier has no coordinates')
items.value = []
} else {
// Get offers near supplier and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: supplier.latitude,
lon: supplier.longitude,
radius: 500
},
'public',
'geo'
)
// 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++
}
})
items.value = Array.from(productsMap.values())
}
} else if (filterHubUuid.value) {
// Products near hub - get hub coordinates first
const hubData = await execute(
GetNodeDocument,
{ uuid: filterHubUuid.value },
'public',
'geo'
)
const hub = hubData?.node
if (!hub?.latitude || !hub?.longitude) {
console.warn('Hub has no coordinates')
items.value = []
} else {
// Get offers near hub and group by product
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
radius: 500
},
'public',
'geo'
)
// 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++
}
})
items.value = Array.from(productsMap.values())
}
} else {
// All products from graph
data = await execute(
ProductsListDocument,
{
limit: 500,
...(filterBounds.value && {
west: filterBounds.value.west,
south: filterBounds.value.south,
east: filterBounds.value.east,
north: filterBounds.value.north
})
},
'public',
'geo'
)
items.value = (data?.productsList || []).filter((p): p is ProductItem => p !== null)
}
isInitialized.value = true
} finally {
isLoading.value = false
}
}
const loadMore = async () => {
// No-op: products don't support pagination yet
}
const init = async () => {
if (!isInitialized.value && items.value.length === 0) {
await fetchProducts()
}
}
// Filter setters
const setSupplierFilter = (uuid: string | null) => {
if (filterSupplierUuid.value !== uuid) {
filterSupplierUuid.value = uuid
filterHubUuid.value = null // clear other filter
isInitialized.value = false
fetchProducts()
}
}
const setHubFilter = (uuid: string | null) => {
if (filterHubUuid.value !== uuid) {
filterHubUuid.value = uuid
filterSupplierUuid.value = null // clear other filter
isInitialized.value = false
fetchProducts()
}
}
const clearFilters = () => {
if (filterSupplierUuid.value || filterHubUuid.value) {
filterSupplierUuid.value = null
filterHubUuid.value = null
isInitialized.value = false
fetchProducts()
}
}
// Products are filtered by offer locations within bounds
const setBoundsFilter = (bounds: { west: number; south: number; east: number; north: number } | null) => {
// Early return if bounds haven't changed
const prev = filterBounds.value
const same = prev === bounds || (
prev && bounds &&
prev.west === bounds.west &&
prev.south === bounds.south &&
prev.east === bounds.east &&
prev.north === bounds.north
)
if (same) return
filterBounds.value = bounds
if (isInitialized.value) {
fetchProducts()
}
}
return {
items,
isLoading,
isLoadingMore,
isInitialized,
canLoadMore,
fetchProducts,
loadMore,
init,
setSupplierFilter,
setHubFilter,
clearFilters,
setBoundsFilter
}
}