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:
51
app/components/catalog/CatalogBreadcrumbs.vue
Normal file
51
app/components/catalog/CatalogBreadcrumbs.vue
Normal 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>
|
||||||
@@ -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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
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