Files
webapp/app/pages/catalog/hubs/[id].vue
Ruslan Bakiev 1c19e5cb78
All checks were successful
Build Docker Image / build (push) Successful in 4m30s
Use Card components instead of buttons for product selection
2026-01-14 23:34:34 +07:00

254 lines
7.6 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>
<CatalogPage
:items="sources"
:loading="isLoadingRoutes"
:with-map="true"
map-id="hub-sources-map"
point-color="#10b981"
v-model:selected-id="selectedSourceUuid"
>
<template #header>
<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 }}</Text>
</div>
</div>
</template>
<template #filters>
<div class="flex gap-3 overflow-x-auto pb-2">
<Card
v-for="product in products"
:key="product.uuid"
padding="sm"
interactive
class="min-w-32 cursor-pointer shrink-0"
:class="selectedProductUuid === product.uuid ? 'ring-2 ring-primary' : ''"
@click="selectedProductUuid = product.uuid"
>
<Text weight="semibold" size="sm">{{ product.name }}</Text>
</Card>
</div>
</template>
<template #card="{ item }">
<Card padding="sm" interactive>
<div class="flex items-center justify-between">
<div>
<Text weight="semibold">{{ item.name }}</Text>
<Text tone="muted" size="sm">{{ selectedProductName }}</Text>
</div>
<div class="text-right">
<Text weight="semibold" class="text-primary">
{{ getOfferPrice(item.uuid) }}
</Text>
<Text tone="muted" size="sm">
{{ formatDistance(item.distanceKm) }} км
</Text>
</div>
</div>
</Card>
</template>
<template #empty>
<Stack align="center" gap="2">
<Icon name="lucide:package-x" size="32" class="text-base-content/40" />
<Text tone="muted">
{{ selectedProductUuid ? t('catalogHub.sources.empty') : t('catalogHub.sources.selectProduct') }}
</Text>
</Stack>
</template>
</CatalogPage>
</template>
</Stack>
</template>
<script setup lang="ts">
import { GetNodeConnectionsDocument, FindProductRoutesDocument } from '~/composables/graphql/public/geo-generated'
import { GetAvailableProductsDocument, GetOfferDocument } from '~/composables/graphql/public/exchange-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 selectedSourceUuid = ref('')
const rawSources = ref<any[]>([])
const offersData = ref<Map<string, any>>(new Map())
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 || ''
})
// Transform sources for CatalogPage (needs uuid, latitude, longitude, name)
const sources = computed(() => {
return rawSources.value.map(source => ({
uuid: source.sourceUuid || '',
name: source.sourceName || '',
latitude: source.sourceLat,
longitude: source.sourceLon,
distanceKm: source.distanceKm,
durationSeconds: source.routes?.[0]?.totalTimeSeconds
}))
})
// Get offer price for display
const getOfferPrice = (uuid: string) => {
const offer = offersData.value.get(uuid)
if (!offer) return '-'
return `${offer.pricePerUnit} ${offer.currency}/${offer.unit}`
}
// Load offer details for prices
const loadOfferDetails = async () => {
if (rawSources.value.length === 0) {
offersData.value.clear()
return
}
const newOffersData = new Map<string, any>()
await Promise.all(rawSources.value.map(async (source) => {
try {
const data = await execute(GetOfferDocument, { uuid: source.sourceUuid }, 'public', 'exchange')
if (data?.getOffer) {
newOffersData.set(source.sourceUuid, data.getOffer)
}
} catch (error) {
console.error('Error loading offer:', source.sourceUuid, error)
}
}))
offersData.value = newOffersData
}
// Load routes when product changes
const loadRoutes = async () => {
if (!selectedProductUuid.value || !hubId.value) {
rawSources.value = []
offersData.value.clear()
return
}
isLoadingRoutes.value = true
selectedSourceUuid.value = ''
try {
const data = await execute(
FindProductRoutesDocument,
{
productUuid: selectedProductUuid.value,
toUuid: hubId.value,
limitSources: 12,
limitRoutes: 1
},
'public',
'geo'
)
rawSources.value = (data?.findProductRoutes || []).filter(Boolean)
await loadOfferDetails()
} catch (error) {
console.error('Error loading routes:', error)
rawSources.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>