All checks were successful
Build Docker Image / build (push) Successful in 4m45s
- Remove Offers, Suppliers, Products, NearbyConnections sections - Add product filter dropdown - Show sources list with routes from findProductRoutes API - Display routes on map using RequestRoutesMap component - Add i18n keys for sources section (en/ru)
255 lines
8.4 KiB
Vue
255 lines
8.4 KiB
Vue
<template>
|
|
<Stack gap="0">
|
|
<!-- Loading -->
|
|
<Section v-if="isLoading" variant="plain" paddingY="lg">
|
|
<Stack align="center" justify="center" gap="4">
|
|
<Spinner />
|
|
<Text tone="muted">{{ t('catalogHub.states.loading') }}</Text>
|
|
</Stack>
|
|
</Section>
|
|
|
|
<!-- Error / Not Found -->
|
|
<Section v-else-if="!hub" variant="plain" paddingY="lg">
|
|
<Card padding="lg">
|
|
<Stack align="center" gap="4">
|
|
<IconCircle tone="primary">
|
|
<Icon name="lucide:map-pin" size="24" />
|
|
</IconCircle>
|
|
<Heading :level="2">{{ t('catalogHub.not_found.title') }}</Heading>
|
|
<Text tone="muted">{{ t('catalogHub.not_found.subtitle') }}</Text>
|
|
<Button @click="navigateTo(localePath('/catalog'))">
|
|
{{ t('catalogHub.actions.back_to_catalog') }}
|
|
</Button>
|
|
</Stack>
|
|
</Card>
|
|
</Section>
|
|
|
|
<template v-else>
|
|
<!-- Header with product filter -->
|
|
<Section variant="plain" paddingY="md">
|
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
<div>
|
|
<Heading :level="1">{{ hub.name }}</Heading>
|
|
<Text tone="muted" size="sm">
|
|
{{ hub.country }}
|
|
<span v-if="hub.latitude && hub.longitude" class="ml-2">
|
|
{{ hub.latitude.toFixed(2) }}°, {{ hub.longitude.toFixed(2) }}°
|
|
</span>
|
|
</Text>
|
|
</div>
|
|
<select
|
|
v-model="selectedProductUuid"
|
|
class="select select-bordered w-full sm:w-64"
|
|
>
|
|
<option value="">{{ t('catalogHub.sources.selectProduct') }}</option>
|
|
<option v-for="product in products" :key="product.uuid" :value="product.uuid">
|
|
{{ product.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</Section>
|
|
|
|
<!-- Split: list + map -->
|
|
<Section variant="plain" paddingY="md">
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
<!-- Sources list -->
|
|
<div class="order-2 lg:order-1">
|
|
<!-- Loading routes -->
|
|
<div v-if="isLoadingRoutes" class="flex items-center justify-center h-64">
|
|
<Stack align="center" gap="2">
|
|
<Spinner />
|
|
<Text tone="muted" size="sm">{{ t('catalogHub.sources.loading') }}</Text>
|
|
</Stack>
|
|
</div>
|
|
|
|
<!-- No product selected -->
|
|
<div v-else-if="!selectedProductUuid" class="flex items-center justify-center h-64">
|
|
<Text tone="muted">{{ t('catalogHub.sources.selectProduct') }}</Text>
|
|
</div>
|
|
|
|
<!-- Sources list -->
|
|
<div v-else-if="sources.length > 0" class="space-y-3 max-h-[600px] overflow-y-auto pr-2">
|
|
<Card
|
|
v-for="source in sources"
|
|
:key="source.sourceUuid"
|
|
padding="sm"
|
|
interactive
|
|
class="cursor-pointer"
|
|
@click="selectSource(source)"
|
|
:class="{ 'ring-2 ring-primary': selectedSourceUuid === source.sourceUuid }"
|
|
>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<Text weight="semibold">{{ source.sourceName }}</Text>
|
|
<Text tone="muted" size="sm">
|
|
{{ selectedProductName }}
|
|
</Text>
|
|
</div>
|
|
<div class="text-right">
|
|
<Text weight="semibold" class="text-primary">
|
|
{{ formatDistance(source.distanceKm) }} км
|
|
</Text>
|
|
<Text tone="muted" size="sm">
|
|
{{ formatDuration(source.routes?.[0]?.totalTimeSeconds) }}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else class="flex items-center justify-center h-64">
|
|
<Stack align="center" gap="2">
|
|
<Icon name="lucide:package-x" size="32" class="text-base-content/40" />
|
|
<Text tone="muted">{{ t('catalogHub.sources.empty') }}</Text>
|
|
</Stack>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Map -->
|
|
<div class="order-1 lg:order-2">
|
|
<RequestRoutesMap :routes="allRoutes" :height="600" />
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
</template>
|
|
</Stack>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { GetNodeConnectionsDocument } from '~/composables/graphql/public/geo-generated'
|
|
import { GetAvailableProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
|
import { FindProductRoutesDocument, type ProductRouteOptionType, type RoutePathType } from '~/composables/graphql/public/geo-generated'
|
|
|
|
definePageMeta({
|
|
layout: 'topnav'
|
|
})
|
|
|
|
const route = useRoute()
|
|
const localePath = useLocalePath()
|
|
const { t } = useI18n()
|
|
const { execute } = useGraphQL()
|
|
|
|
const isLoading = ref(true)
|
|
const isLoadingRoutes = ref(false)
|
|
const hub = ref<any>(null)
|
|
const products = ref<Array<{ uuid: string; name: string }>>([])
|
|
const selectedProductUuid = ref('')
|
|
const sources = ref<ProductRouteOptionType[]>([])
|
|
const selectedSourceUuid = ref<string | null>(null)
|
|
|
|
const hubId = computed(() => route.params.id as string)
|
|
|
|
// Selected product name
|
|
const selectedProductName = computed(() => {
|
|
const product = products.value.find(p => p.uuid === selectedProductUuid.value)
|
|
return product?.name || ''
|
|
})
|
|
|
|
// All routes for map (flatten sources -> routes)
|
|
const allRoutes = computed(() => {
|
|
if (selectedSourceUuid.value) {
|
|
const source = sources.value.find(s => s.sourceUuid === selectedSourceUuid.value)
|
|
return (source?.routes || []).filter((r): r is RoutePathType => r !== null)
|
|
}
|
|
return sources.value.flatMap(s => (s.routes || []).filter((r): r is RoutePathType => r !== null))
|
|
})
|
|
|
|
const selectSource = (source: ProductRouteOptionType) => {
|
|
if (selectedSourceUuid.value === source.sourceUuid) {
|
|
selectedSourceUuid.value = null
|
|
} else {
|
|
selectedSourceUuid.value = source.sourceUuid || null
|
|
}
|
|
}
|
|
|
|
// Load routes when product changes
|
|
const loadRoutes = async () => {
|
|
if (!selectedProductUuid.value || !hubId.value) {
|
|
sources.value = []
|
|
return
|
|
}
|
|
|
|
isLoadingRoutes.value = true
|
|
selectedSourceUuid.value = null
|
|
|
|
try {
|
|
const data = await execute(
|
|
FindProductRoutesDocument,
|
|
{
|
|
productUuid: selectedProductUuid.value,
|
|
toUuid: hubId.value,
|
|
limitSources: 12,
|
|
limitRoutes: 1
|
|
},
|
|
'public',
|
|
'geo'
|
|
)
|
|
sources.value = (data?.findProductRoutes || []).filter((s): s is ProductRouteOptionType => s !== null)
|
|
} catch (error) {
|
|
console.error('Error loading routes:', error)
|
|
sources.value = []
|
|
} finally {
|
|
isLoadingRoutes.value = false
|
|
}
|
|
}
|
|
|
|
watch(selectedProductUuid, loadRoutes)
|
|
|
|
// Formatting helpers
|
|
const formatDistance = (km: number | null | undefined) => {
|
|
if (!km) return '0'
|
|
return Math.round(km).toLocaleString()
|
|
}
|
|
|
|
const formatDuration = (seconds: number | null | undefined) => {
|
|
if (!seconds) return '-'
|
|
const hours = Math.floor(seconds / 3600)
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
if (hours > 24) {
|
|
const days = Math.floor(hours / 24)
|
|
const remainingHours = hours % 24
|
|
return `${days}д ${remainingHours}ч`
|
|
}
|
|
if (hours > 0) {
|
|
return `${hours}ч ${minutes}м`
|
|
}
|
|
return `${minutes}м`
|
|
}
|
|
|
|
// Initial load
|
|
try {
|
|
const [{ data: connectionsData }, { data: productsData }] = await Promise.all([
|
|
useServerQuery('hub-connections', GetNodeConnectionsDocument, { uuid: hubId.value }, 'public', 'geo'),
|
|
useServerQuery('available-products', GetAvailableProductsDocument, {}, 'public', 'exchange')
|
|
])
|
|
|
|
hub.value = connectionsData.value?.nodeConnections?.hub || null
|
|
products.value = (productsData.value?.getAvailableProducts || [])
|
|
.filter((p): p is { uuid: string; name: string } => p !== null && !!p.uuid && !!p.name)
|
|
.map(p => ({ uuid: p.uuid!, name: p.name! }))
|
|
} 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 || '',
|
|
offers: sources.value.length,
|
|
suppliers: 0
|
|
})
|
|
}
|
|
]
|
|
}))
|
|
</script>
|