Files
webapp/app/pages/catalog/hubs/[id]/index.vue
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

190 lines
5.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<CatalogPage
:items="filteredProducts"
:map-items="mapItems"
:loading="isLoading"
:total-count="products.length"
with-map
map-id="hub-products-map"
point-color="#10b981"
:hovered-id="hoveredId"
@update:hovered-id="hoveredId = $event"
>
<template #searchBar="{ displayedCount, totalCount }">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="navigationFilters"
:displayed-count="displayedCount"
:total-count="totalCount"
@remove-filter="handleRemoveFilter"
/>
</template>
<template #header>
<Text v-if="!isLoading && !hub" tone="muted">Хаб не найден</Text>
<Text v-else-if="!isLoading" tone="muted">Выберите товар</Text>
</template>
<template #card="{ item }">
<ProductCard
:product="item"
:price-history="getMockPriceHistory(item.uuid)"
selectable
@select="goToProduct(item.uuid)"
/>
</template>
<template #empty>
<Stack align="center" gap="2">
<Icon name="lucide:package-x" size="32" class="text-base-content/40" />
<Text tone="muted">{{ t('catalogHub.products.empty') }}</Text>
</Stack>
</template>
</CatalogPage>
</template>
<script setup lang="ts">
import { GetNodeDocument, NearestOffersDocument, type OfferWithRouteType, type GetNodeQueryResult } from '~/composables/graphql/public/geo-generated'
type Hub = NonNullable<GetNodeQueryResult['node']>
definePageMeta({
layout: 'topnav'
})
const route = useRoute()
const localePath = useLocalePath()
const { t } = useI18n()
const isLoading = ref(true)
const hoveredId = ref<string>()
const hub = ref<Hub | null>(null)
const products = ref<Array<{ uuid: string; name: string }>>([])
const hubId = computed(() => route.params.id as string)
// Navigation filters for search bar badges
const navigationFilters = computed(() => {
const filters: Array<{ id: string; label: string; key: string }> = []
if (hub.value?.name) {
filters.push({
id: 'hub',
key: 'Хаб',
label: hub.value.name
})
}
return filters
})
// Handle removing navigation filter (navigate back)
const handleRemoveFilter = (filterId: string) => {
if (filterId === 'hub') {
navigateTo(localePath('/catalog?select=hub'))
}
}
// Search
const searchQuery = ref('')
const filteredProducts = computed(() => {
if (!searchQuery.value.trim()) return products.value
const q = searchQuery.value.toLowerCase()
return products.value.filter(item =>
item.name?.toLowerCase().includes(q)
)
})
// Map items - show the hub itself
const mapItems = computed(() => {
if (!hub.value?.latitude || !hub.value?.longitude) return []
return [{
uuid: hub.value.uuid || hubId.value,
name: hub.value.name || '',
latitude: Number(hub.value.latitude),
longitude: Number(hub.value.longitude),
country: hub.value.country
}]
})
// Navigate to unified catalog with hub + product = offers
const goToProduct = (productId: string) => {
navigateTo(localePath(`/catalog?hub=${hubId.value}&product=${productId}`))
}
// Mock price history generator (seeded by uuid for consistent results)
const getMockPriceHistory = (uuid: string): number[] => {
const seed = uuid.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const basePrice = 100 + (seed % 200)
return Array.from({ length: 7 }, (_, i) => {
const variation = Math.sin(seed + i * 0.5) * 20 + Math.cos(seed * 0.3 + i) * 10
return Math.round(basePrice + variation)
})
}
// Initial load
try {
// First load hub node to get coordinates
const { data: hubData } = await useServerQuery(
'hub-node',
GetNodeDocument,
{ uuid: hubId.value },
'public',
'geo'
)
hub.value = hubData.value?.node || null
// Load offers near hub and group by product
if (hub.value?.latitude && hub.value?.longitude) {
const { data: offersData } = await useServerQuery(
'offers-near-hub',
NearestOffersDocument,
{
lat: hub.value.latitude,
lon: hub.value.longitude,
radius: 500
},
'public',
'geo'
)
// Group offers by product
const productsMap = new Map<string, { uuid: string; name: string }>()
offersData.value?.nearestOffers?.forEach((offer) => {
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
uuid: offer.productUuid,
name: offer.productName || ''
})
}
}
})
products.value = Array.from(productsMap.values())
}
} catch (error) {
console.error('Error loading hub:', error)
} finally {
isLoading.value = false
}
// SEO
useHead(() => ({
title: hub.value?.name
? t('catalogHub.meta.title_with_name', { name: hub.value.name })
: t('catalogHub.meta.title'),
meta: [
{
name: 'description',
content: t('catalogHub.meta.description', {
name: hub.value?.name || '',
country: hub.value?.country || '',
products: products.value.length
})
}
]
}))
</script>