feat: вложенные роуты хаб/товар с хлебными крошками
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:
Ruslan Bakiev
2026-01-16 00:25:41 +07:00
parent bab0e9e539
commit 210d3e935c
3 changed files with 300 additions and 73 deletions

View File

@@ -0,0 +1,51 @@
<template>
<div class="breadcrumbs text-sm">
<ul>
<li v-for="(crumb, index) in breadcrumbs" :key="index">
<NuxtLink v-if="crumb.to" :to="crumb.to" class="hover:text-primary">
{{ crumb.label }}
</NuxtLink>
<span v-else class="text-base-content">{{ crumb.label }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
hubId?: string
hubName?: string
productId?: string
productName?: string
}>()
const localePath = useLocalePath()
const { t } = useI18n()
const breadcrumbs = computed(() => {
const crumbs: Array<{ label: string; to?: string }> = []
// Hubs list
crumbs.push({
label: t('breadcrumbs.hubs', 'Hubs'),
to: localePath('/catalog/hubs')
})
// Hub
if (props.hubId) {
crumbs.push({
label: props.hubName || `#${props.hubId.slice(0, 8)}...`,
to: props.productId ? localePath(`/catalog/hubs/${props.hubId}`) : undefined
})
}
// Product
if (props.productId) {
crumbs.push({
label: props.productName || `#${props.productId.slice(0, 8)}...`
})
}
return crumbs
})
</script>

View File

@@ -9,56 +9,70 @@
</Section> </Section>
<!-- Error / Not Found --> <!-- 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"> <Card padding="lg">
<Stack align="center" gap="4"> <Stack align="center" gap="4">
<IconCircle tone="primary"> <IconCircle tone="primary">
<Icon name="lucide:map-pin" size="24" /> <Icon name="lucide:package-x" size="24" />
</IconCircle> </IconCircle>
<Heading :level="2">{{ t('catalogHub.not_found.title') }}</Heading> <Heading :level="2">{{ t('catalogHub.product_not_found.title') }}</Heading>
<Text tone="muted">{{ t('catalogHub.not_found.subtitle') }}</Text> <Text tone="muted">{{ t('catalogHub.product_not_found.subtitle') }}</Text>
<Button @click="navigateTo(localePath('/catalog'))"> <Button @click="navigateTo(localePath(`/catalog/hubs/${hubId}`))">
{{ t('catalogHub.actions.back_to_catalog') }} {{ t('catalogHub.actions.back_to_hub') }}
</Button> </Button>
</Stack> </Stack>
</Card> </Card>
</Section> </Section>
<!-- Content -->
<template v-else> <template v-else>
<CatalogPage <CatalogPage
:items="sources" :items="sources"
:loading="isLoadingRoutes" :loading="isLoadingRoutes"
:with-map="true" :with-map="true"
map-id="hub-sources-map" map-id="hub-product-sources-map"
point-color="#10b981" point-color="#10b981"
v-model:selected-id="selectedSourceUuid" v-model:selected-id="selectedSourceUuid"
> >
<template #header> <template #header>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <Stack gap="3">
<div> <!-- Breadcrumbs -->
<Heading :level="1">{{ hub.name }}</Heading> <CatalogBreadcrumbs
<Text tone="muted" size="sm">{{ hub.country }}</Text> :hub-id="hubId"
</div> :hub-name="hub.name"
</div> :product-id="productId"
</template> :product-name="product.name"
<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"
/> />
<!-- Product info -->
<div>
<Heading :level="1">{{ product.name }}</Heading>
<Text tone="muted" size="sm">{{ hub.name }}, {{ hub.country }}</Text>
</div> </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>
<template #card="{ item }"> <template #card="{ item }">
<OfferResultCard <OfferResultCard
:location-name="getOfferData(item.uuid)?.locationName" :location-name="getOfferData(item.uuid)?.locationName"
:product-name="selectedProductName" :product-name="product.name"
:price-per-unit="getOfferData(item.uuid)?.pricePerUnit" :price-per-unit="getOfferData(item.uuid)?.pricePerUnit"
:currency="getOfferData(item.uuid)?.currency" :currency="getOfferData(item.uuid)?.currency"
:unit="getOfferData(item.uuid)?.unit" :unit="getOfferData(item.uuid)?.unit"
@@ -69,7 +83,10 @@
</template> </template>
<template #empty> <template #empty>
<Stack align="center" gap="2">
<Icon name="lucide:truck" size="32" class="text-base-content/40" />
<Text tone="muted">{{ t('catalogHub.sources.empty') }}</Text> <Text tone="muted">{{ t('catalogHub.sources.empty') }}</Text>
</Stack>
</template> </template>
</CatalogPage> </CatalogPage>
</template> </template>
@@ -92,31 +109,81 @@ const { execute } = useGraphQL()
const isLoading = ref(true) const isLoading = ref(true)
const isLoadingRoutes = ref(false) const isLoadingRoutes = ref(false)
const hub = ref<any>(null) const hub = ref<any>(null)
const products = ref<Array<{ uuid: string; name: string }>>([]) const product = ref<{ uuid: string; name: string } | null>(null)
const selectedProductUuid = ref('')
const selectedSourceUuid = ref('') const selectedSourceUuid = ref('')
const rawSources = ref<any[]>([]) const rawSources = ref<any[]>([])
const offersData = ref<Map<string, any>>(new Map()) 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) // Mock price history generator (seeded by uuid for consistent results)
const getMockPriceHistory = (uuid: string): number[] => { const getMockPriceHistory = (uuid: string): number[] => {
const seed = uuid.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) const seed = uuid.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const basePrice = 100 + (seed % 200) const basePrice = 100 + (seed % 200)
return Array.from({ length: 7 }, (_, i) => { return Array.from({ length: 30 }, (_, i) => {
const variation = Math.sin(seed + i * 0.5) * 20 + Math.cos(seed * 0.3 + i) * 10 const variation = Math.sin(seed + i * 0.3) * 30 + Math.cos(seed * 0.2 + i) * 15
return Math.round(basePrice + variation) 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 trend = computed(() => {
const selectedProductName = computed(() => { if (priceHistory.value.length < 2) return 0
const product = products.value.find(p => p.uuid === selectedProductUuid.value) const first = priceHistory.value[0]
return product?.name || '' 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(() => { const sources = computed(() => {
return rawSources.value.map(source => ({ return rawSources.value.map(source => ({
uuid: source.sourceUuid || '', uuid: source.sourceUuid || '',
@@ -158,9 +225,9 @@ const loadOfferDetails = async () => {
offersData.value = newOffersData offersData.value = newOffersData
} }
// Load routes when product changes // Load routes
const loadRoutes = async () => { const loadRoutes = async () => {
if (!selectedProductUuid.value || !hubId.value) { if (!productId.value || !hubId.value) {
rawSources.value = [] rawSources.value = []
offersData.value.clear() offersData.value.clear()
return return
@@ -173,7 +240,7 @@ const loadRoutes = async () => {
const data = await execute( const data = await execute(
FindProductRoutesDocument, FindProductRoutesDocument,
{ {
productUuid: selectedProductUuid.value, productUuid: productId.value,
toUuid: hubId.value, toUuid: hubId.value,
limitSources: 12, limitSources: 12,
limitRoutes: 1 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 // Initial load
try { try {
const [{ data: connectionsData }, { data: productsData }] = await Promise.all([ const [{ data: connectionsData }, { data: productsData }] = await Promise.all([
@@ -222,28 +266,34 @@ try {
]) ])
hub.value = connectionsData.value?.nodeConnections?.hub || null 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) .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) { } catch (error) {
console.error('Error loading hub:', error) console.error('Error loading data:', error)
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
// SEO // SEO
useHead(() => ({ useHead(() => ({
title: hub.value?.name title: product.value?.name && hub.value?.name
? t('catalogHub.meta.title_with_name', { name: hub.value.name }) ? `${product.value.name} - ${hub.value.name}`
: t('catalogHub.meta.title'), : t('catalogHub.meta.title'),
meta: [ meta: [
{ {
name: 'description', name: 'description',
content: t('catalogHub.meta.description', { content: t('catalogHub.product.meta.description', {
name: hub.value?.name || '', product: product.value?.name || '',
country: hub.value?.country || '', hub: hub.value?.name || '',
offers: sources.value.length, offers: sources.value.length
suppliers: 0
}) })
} }
] ]

View 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>