feat: вложенные роуты хаб/товар с хлебными крошками
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
All checks were successful
Build Docker Image / build (push) Successful in 4m34s
- /catalog/hubs/[id] — список товаров с графиками - /catalog/hubs/[id]/[productId] — страница товара с большим графиком и предложениями - CatalogBreadcrumbs — компонент хлебных крошек
This commit is contained in:
@@ -9,56 +9,70 @@
|
||||
</Section>
|
||||
|
||||
<!-- Error / Not Found -->
|
||||
<Section v-else-if="!hub" variant="plain" paddingY="lg">
|
||||
<Section v-else-if="!hub || !product" variant="plain" paddingY="lg">
|
||||
<Card padding="lg">
|
||||
<Stack align="center" gap="4">
|
||||
<IconCircle tone="primary">
|
||||
<Icon name="lucide:map-pin" size="24" />
|
||||
<Icon name="lucide:package-x" 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') }}
|
||||
<Heading :level="2">{{ t('catalogHub.product_not_found.title') }}</Heading>
|
||||
<Text tone="muted">{{ t('catalogHub.product_not_found.subtitle') }}</Text>
|
||||
<Button @click="navigateTo(localePath(`/catalog/hubs/${hubId}`))">
|
||||
{{ t('catalogHub.actions.back_to_hub') }}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<!-- Content -->
|
||||
<template v-else>
|
||||
<CatalogPage
|
||||
:items="sources"
|
||||
:loading="isLoadingRoutes"
|
||||
:with-map="true"
|
||||
map-id="hub-sources-map"
|
||||
map-id="hub-product-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="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<HubProductCard
|
||||
v-for="product in products"
|
||||
:key="product.uuid"
|
||||
:name="product.name"
|
||||
:price-history="getMockPriceHistory(product.uuid)"
|
||||
:selected="selectedProductUuid === product.uuid"
|
||||
@select="selectedProductUuid = product.uuid"
|
||||
<Stack gap="3">
|
||||
<!-- Breadcrumbs -->
|
||||
<CatalogBreadcrumbs
|
||||
:hub-id="hubId"
|
||||
:hub-name="hub.name"
|
||||
:product-id="productId"
|
||||
:product-name="product.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Product info -->
|
||||
<div>
|
||||
<Heading :level="1">{{ product.name }}</Heading>
|
||||
<Text tone="muted" size="sm">{{ hub.name }}, {{ hub.country }}</Text>
|
||||
</div>
|
||||
|
||||
<!-- Price chart -->
|
||||
<Card padding="md">
|
||||
<Stack gap="2">
|
||||
<Text weight="semibold" size="sm">{{ t('catalogHub.product.priceHistory') }}</Text>
|
||||
<div class="h-48">
|
||||
<ClientOnly>
|
||||
<apexchart
|
||||
type="area"
|
||||
height="180"
|
||||
:options="chartOptions"
|
||||
:series="chartSeries"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<template #card="{ item }">
|
||||
<OfferResultCard
|
||||
:location-name="getOfferData(item.uuid)?.locationName"
|
||||
:product-name="selectedProductName"
|
||||
:product-name="product.name"
|
||||
:price-per-unit="getOfferData(item.uuid)?.pricePerUnit"
|
||||
:currency="getOfferData(item.uuid)?.currency"
|
||||
:unit="getOfferData(item.uuid)?.unit"
|
||||
@@ -69,7 +83,10 @@
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<Text tone="muted">{{ t('catalogHub.sources.empty') }}</Text>
|
||||
<Stack align="center" gap="2">
|
||||
<Icon name="lucide:truck" size="32" class="text-base-content/40" />
|
||||
<Text tone="muted">{{ t('catalogHub.sources.empty') }}</Text>
|
||||
</Stack>
|
||||
</template>
|
||||
</CatalogPage>
|
||||
</template>
|
||||
@@ -92,31 +109,81 @@ 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 product = ref<{ uuid: string; name: string } | null>(null)
|
||||
const selectedSourceUuid = ref('')
|
||||
const rawSources = ref<any[]>([])
|
||||
const offersData = ref<Map<string, any>>(new Map())
|
||||
|
||||
const hubId = computed(() => route.params.id as string)
|
||||
const productId = computed(() => route.params.productId as string)
|
||||
|
||||
// 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 Array.from({ length: 30 }, (_, i) => {
|
||||
const variation = Math.sin(seed + i * 0.3) * 30 + Math.cos(seed * 0.2 + i) * 15
|
||||
return Math.round(basePrice + variation)
|
||||
})
|
||||
}
|
||||
|
||||
const hubId = computed(() => route.params.id as string)
|
||||
// Chart configuration
|
||||
const priceHistory = computed(() => getMockPriceHistory(productId.value))
|
||||
|
||||
// Selected product name
|
||||
const selectedProductName = computed(() => {
|
||||
const product = products.value.find(p => p.uuid === selectedProductUuid.value)
|
||||
return product?.name || ''
|
||||
const trend = computed(() => {
|
||||
if (priceHistory.value.length < 2) return 0
|
||||
const first = priceHistory.value[0]
|
||||
const last = priceHistory.value[priceHistory.value.length - 1]
|
||||
if (!first || first === 0) return 0
|
||||
return Math.round(((last - first) / first) * 100)
|
||||
})
|
||||
|
||||
// Transform sources for CatalogPage (needs uuid, latitude, longitude, name, stages)
|
||||
const chartOptions = computed(() => ({
|
||||
chart: {
|
||||
type: 'area',
|
||||
toolbar: { show: false },
|
||||
animations: { enabled: true }
|
||||
},
|
||||
stroke: {
|
||||
curve: 'smooth',
|
||||
width: 2
|
||||
},
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.4,
|
||||
opacityTo: 0.1
|
||||
}
|
||||
},
|
||||
colors: [trend.value >= 0 ? '#22c55e' : '#ef4444'],
|
||||
dataLabels: { enabled: false },
|
||||
xaxis: {
|
||||
categories: priceHistory.value.map((_, i) => `${i + 1}`),
|
||||
labels: { show: false }
|
||||
},
|
||||
yaxis: {
|
||||
labels: {
|
||||
formatter: (val: number) => `$${val}`
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
y: {
|
||||
formatter: (val: number) => `$${val}`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
borderColor: '#e5e7eb',
|
||||
strokeDashArray: 4
|
||||
}
|
||||
}))
|
||||
|
||||
const chartSeries = computed(() => [{
|
||||
name: t('catalogHub.product.price'),
|
||||
data: priceHistory.value
|
||||
}])
|
||||
|
||||
// Transform sources for CatalogPage
|
||||
const sources = computed(() => {
|
||||
return rawSources.value.map(source => ({
|
||||
uuid: source.sourceUuid || '',
|
||||
@@ -158,9 +225,9 @@ const loadOfferDetails = async () => {
|
||||
offersData.value = newOffersData
|
||||
}
|
||||
|
||||
// Load routes when product changes
|
||||
// Load routes
|
||||
const loadRoutes = async () => {
|
||||
if (!selectedProductUuid.value || !hubId.value) {
|
||||
if (!productId.value || !hubId.value) {
|
||||
rawSources.value = []
|
||||
offersData.value.clear()
|
||||
return
|
||||
@@ -173,7 +240,7 @@ const loadRoutes = async () => {
|
||||
const data = await execute(
|
||||
FindProductRoutesDocument,
|
||||
{
|
||||
productUuid: selectedProductUuid.value,
|
||||
productUuid: productId.value,
|
||||
toUuid: hubId.value,
|
||||
limitSources: 12,
|
||||
limitRoutes: 1
|
||||
@@ -191,29 +258,6 @@ const loadRoutes = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
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([
|
||||
@@ -222,28 +266,34 @@ try {
|
||||
])
|
||||
|
||||
hub.value = connectionsData.value?.nodeConnections?.hub || null
|
||||
products.value = (productsData.value?.getAvailableProducts || [])
|
||||
|
||||
const products = (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! }))
|
||||
|
||||
product.value = products.find(p => p.uuid === productId.value) || null
|
||||
|
||||
// Load routes after initial data
|
||||
if (product.value) {
|
||||
await loadRoutes()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading hub:', error)
|
||||
console.error('Error loading data:', error)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
// SEO
|
||||
useHead(() => ({
|
||||
title: hub.value?.name
|
||||
? t('catalogHub.meta.title_with_name', { name: hub.value.name })
|
||||
title: product.value?.name && hub.value?.name
|
||||
? `${product.value.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
|
||||
content: t('catalogHub.product.meta.description', {
|
||||
product: product.value?.name || '',
|
||||
hub: hub.value?.name || '',
|
||||
offers: sources.value.length
|
||||
})
|
||||
}
|
||||
]
|
||||
126
app/pages/catalog/hubs/[id]/index.vue
Normal file
126
app/pages/catalog/hubs/[id]/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<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>
|
||||
|
||||
<!-- Content -->
|
||||
<Section v-else variant="plain" paddingY="lg">
|
||||
<Stack gap="4">
|
||||
<!-- Breadcrumbs -->
|
||||
<CatalogBreadcrumbs :hub-id="hubId" :hub-name="hub.name" />
|
||||
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<Heading :level="1">{{ hub.name }}</Heading>
|
||||
<Text tone="muted" size="sm">{{ hub.country }}</Text>
|
||||
</div>
|
||||
|
||||
<!-- Products grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<HubProductCard
|
||||
v-for="product in products"
|
||||
:key="product.uuid"
|
||||
:name="product.name"
|
||||
:price-history="getMockPriceHistory(product.uuid)"
|
||||
@select="goToProduct(product.uuid)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<Stack v-if="products.length === 0" 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>
|
||||
</Stack>
|
||||
</Section>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetNodeConnectionsDocument } from '~/composables/graphql/public/geo-generated'
|
||||
import { GetAvailableProductsDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'topnav'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isLoading = ref(true)
|
||||
const hub = ref<any>(null)
|
||||
const products = ref<Array<{ uuid: string; name: string }>>([])
|
||||
|
||||
const hubId = computed(() => route.params.id as string)
|
||||
|
||||
// Navigate to product page
|
||||
const goToProduct = (productId: string) => {
|
||||
navigateTo(localePath(`/catalog/hubs/${hubId.value}/${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 {
|
||||
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 || '',
|
||||
products: products.value.length
|
||||
})
|
||||
}
|
||||
]
|
||||
}))
|
||||
</script>
|
||||
Reference in New Issue
Block a user