Files
webapp/app/composables/useCatalogProducts.ts
Ruslan Bakiev 2dbe600d8a
All checks were successful
Build Docker Image / build (push) Successful in 4m3s
refactor: remove all any types, add strict GraphQL scalar typing
- Add strictScalars: true to codegen.ts with proper scalar mappings
  (Date, Decimal, JSONString, JSON, UUID, BigInt → string/Record)
- Replace all ref<any[]> with proper GraphQL-derived types
- Add type guards for null filtering in arrays
- Fix bugs exposed by typing (locationLatitude vs latitude, etc.)
- Add interfaces for external components (MapboxSearchBox)

This enables end-to-end type safety from GraphQL schema to frontend.
2026-01-27 11:34:12 +07:00

224 lines
6.4 KiB
TypeScript

import type { ProductsListQueryResult, NearestOffersQueryResult } 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]>
type OfferItem = NonNullable<NonNullable<NearestOffersQueryResult['nearestOffers']>[number]>
// Product aggregated from offers
interface AggregatedProduct {
uuid: string
name: string | null | undefined
offersCount: 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, AggregatedProduct>()
offersData?.nearestOffers?.forEach((offer) => {
if (!offer?.productUuid) return
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()) as ProductItem[]
}
} 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, AggregatedProduct>()
offersData?.nearestOffers?.forEach((offer) => {
if (!offer?.productUuid) return
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()) as ProductItem[]
}
} 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
}
}