refactor(hub): use CatalogPage component for split layout
All checks were successful
Build Docker Image / build (push) Successful in 4m19s

This commit is contained in:
Ruslan Bakiev
2026-01-09 01:33:03 +07:00
parent cd2c8afff1
commit fbcfc6caf8

View File

@@ -25,101 +25,75 @@
</Section> </Section>
<template v-else> <template v-else>
<!-- Header with product filter --> <CatalogPage
<Section variant="plain" paddingY="md"> :items="sources"
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> :loading="isLoadingRoutes"
<div> :with-map="true"
<Heading :level="1">{{ hub.name }}</Heading> map-id="hub-sources-map"
<Text tone="muted" size="sm"> point-color="#10b981"
{{ hub.country }} v-model:selected-id="selectedSourceUuid"
<span v-if="hub.latitude && hub.longitude" class="ml-2"> >
{{ hub.latitude.toFixed(2) }}°, {{ hub.longitude.toFixed(2) }}° <template #header>
</span> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
</Text> <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>
</div> </div>
</template>
<template #filters>
<select <select
v-model="selectedProductUuid" v-model="selectedProductUuid"
class="select select-bordered w-full sm:w-64" class="select select-bordered w-full"
> >
<option value="">{{ t('catalogHub.sources.selectProduct') }}</option> <option value="">{{ t('catalogHub.sources.selectProduct') }}</option>
<option v-for="product in products" :key="product.uuid" :value="product.uuid"> <option v-for="product in products" :key="product.uuid" :value="product.uuid">
{{ product.name }} {{ product.name }}
</option> </option>
</select> </select>
</div> </template>
</Section>
<!-- Split: list + map --> <template #card="{ item }">
<Section variant="plain" paddingY="md"> <Card padding="sm" interactive>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div class="flex items-center justify-between">
<!-- Sources list --> <div>
<div class="order-2 lg:order-1"> <Text weight="semibold">{{ item.name }}</Text>
<!-- Loading routes --> <Text tone="muted" size="sm">{{ selectedProductName }}</Text>
<div v-if="isLoadingRoutes" class="flex items-center justify-center h-64"> </div>
<Stack align="center" gap="2"> <div class="text-right">
<Spinner /> <Text weight="semibold" class="text-primary">
<Text tone="muted" size="sm">{{ t('catalogHub.sources.loading') }}</Text> {{ formatDistance(item.distanceKm) }} км
</Stack> </Text>
<Text tone="muted" size="sm">
{{ formatDuration(item.durationSeconds) }}
</Text>
</div>
</div> </div>
</Card>
</template>
<!-- No product selected --> <template #empty>
<div v-else-if="!selectedProductUuid" class="flex items-center justify-center h-64"> <Stack align="center" gap="2">
<Text tone="muted">{{ t('catalogHub.sources.selectProduct') }}</Text> <Icon name="lucide:package-x" size="32" class="text-base-content/40" />
</div> <Text tone="muted">
{{ selectedProductUuid ? t('catalogHub.sources.empty') : t('catalogHub.sources.selectProduct') }}
<!-- Sources list --> </Text>
<div v-else-if="sources.length > 0" class="space-y-3 max-h-[600px] overflow-y-auto pr-2"> </Stack>
<Card </template>
v-for="source in sources" </CatalogPage>
: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> </template>
</Stack> </Stack>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { GetNodeConnectionsDocument } from '~/composables/graphql/public/geo-generated' import { GetNodeConnectionsDocument, FindProductRoutesDocument } from '~/composables/graphql/public/geo-generated'
import { GetAvailableProductsDocument } from '~/composables/graphql/public/exchange-generated' import { GetAvailableProductsDocument } from '~/composables/graphql/public/exchange-generated'
import { FindProductRoutesDocument, type ProductRouteOptionType, type RoutePathType } from '~/composables/graphql/public/geo-generated'
definePageMeta({ definePageMeta({
layout: 'topnav' layout: 'topnav'
@@ -135,8 +109,8 @@ const isLoadingRoutes = ref(false)
const hub = ref<any>(null) const hub = ref<any>(null)
const products = ref<Array<{ uuid: string; name: string }>>([]) const products = ref<Array<{ uuid: string; name: string }>>([])
const selectedProductUuid = ref('') const selectedProductUuid = ref('')
const sources = ref<ProductRouteOptionType[]>([]) const selectedSourceUuid = ref('')
const selectedSourceUuid = ref<string | null>(null) const rawSources = ref<any[]>([])
const hubId = computed(() => route.params.id as string) const hubId = computed(() => route.params.id as string)
@@ -146,32 +120,27 @@ const selectedProductName = computed(() => {
return product?.name || '' return product?.name || ''
}) })
// All routes for map (flatten sources -> routes) // Transform sources for CatalogPage (needs uuid, latitude, longitude, name)
const allRoutes = computed(() => { const sources = computed(() => {
if (selectedSourceUuid.value) { return rawSources.value.map(source => ({
const source = sources.value.find(s => s.sourceUuid === selectedSourceUuid.value) uuid: source.sourceUuid || '',
return (source?.routes || []).filter((r): r is RoutePathType => r !== null) name: source.sourceName || '',
} latitude: source.sourceLat,
return sources.value.flatMap(s => (s.routes || []).filter((r): r is RoutePathType => r !== null)) longitude: source.sourceLon,
distanceKm: source.distanceKm,
durationSeconds: source.routes?.[0]?.totalTimeSeconds
}))
}) })
const selectSource = (source: ProductRouteOptionType) => {
if (selectedSourceUuid.value === source.sourceUuid) {
selectedSourceUuid.value = null
} else {
selectedSourceUuid.value = source.sourceUuid || null
}
}
// Load routes when product changes // Load routes when product changes
const loadRoutes = async () => { const loadRoutes = async () => {
if (!selectedProductUuid.value || !hubId.value) { if (!selectedProductUuid.value || !hubId.value) {
sources.value = [] rawSources.value = []
return return
} }
isLoadingRoutes.value = true isLoadingRoutes.value = true
selectedSourceUuid.value = null selectedSourceUuid.value = ''
try { try {
const data = await execute( const data = await execute(
@@ -185,10 +154,10 @@ const loadRoutes = async () => {
'public', 'public',
'geo' 'geo'
) )
sources.value = (data?.findProductRoutes || []).filter((s): s is ProductRouteOptionType => s !== null) rawSources.value = (data?.findProductRoutes || []).filter(Boolean)
} catch (error) { } catch (error) {
console.error('Error loading routes:', error) console.error('Error loading routes:', error)
sources.value = [] rawSources.value = []
} finally { } finally {
isLoadingRoutes.value = false isLoadingRoutes.value = false
} }