All checks were successful
Build Docker Image / build (push) Successful in 4m3s
- 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.
190 lines
5.0 KiB
Vue
190 lines
5.0 KiB
Vue
<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>
|