refactor(hub): use CatalogPage component for split layout
All checks were successful
Build Docker Image / build (push) Successful in 4m19s
All checks were successful
Build Docker Image / build (push) Successful in 4m19s
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user