feat: unified catalog page with list/map toggle
- Add viewMode toggle (Список/Карта) to catalog page - Create CatalogHubsSection with country grouping - Create CatalogOffersSection with status badges - Create CatalogSuppliersSection with verification badges - Create CatalogMapView with left panel tabs (Хабы/Офферы/Компании) - Create CatalogMapItem for clickable list items with flyTo animation - Integrate with existing MapboxGlobe component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
69
webapp/app/components/catalog/CatalogHubsSection.vue
Normal file
69
webapp/app/components/catalog/CatalogHubsSection.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">Ключевые хабы и страны</Heading>
|
||||
<NuxtLink :to="localePath('/catalog/hubs')">
|
||||
<Button variant="secondary" size="small">Смотреть все</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="6">
|
||||
<div v-for="country in hubsByCountry" :key="country.name">
|
||||
<Text weight="semibold" class="mb-3">{{ country.name }}</Text>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="hub in country.hubs"
|
||||
:key="hub.uuid"
|
||||
padding="sm"
|
||||
tone="muted"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ hub.name }}</Text>
|
||||
<Text tone="muted">{{ hub.country }}</Text>
|
||||
</Stack>
|
||||
<Text v-if="hub.distance" tone="muted" size="base">{{ hub.distance }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Hub {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
distance?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hubs: Hub[]
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const hubsByCountry = computed(() => {
|
||||
const grouped = new Map<string, Hub[]>()
|
||||
|
||||
props.hubs.forEach(hub => {
|
||||
const country = hub.country || 'Другие'
|
||||
if (!grouped.has(country)) {
|
||||
grouped.set(country, [])
|
||||
}
|
||||
grouped.get(country)!.push(hub)
|
||||
})
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.map(([name, hubs]) => ({ name, hubs }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
</script>
|
||||
57
webapp/app/components/catalog/CatalogMapItem.vue
Normal file
57
webapp/app/components/catalog/CatalogMapItem.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div
|
||||
class="p-3 rounded-lg cursor-pointer transition-all"
|
||||
:class="[
|
||||
isSelected
|
||||
? 'bg-primary/10 border-2 border-primary'
|
||||
: 'bg-gray-50 hover:bg-gray-100 border-2 border-transparent'
|
||||
]"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" align="start" gap="3">
|
||||
<IconCircle :tone="iconTone" size="sm">
|
||||
<Icon :name="iconName" size="16" />
|
||||
</IconCircle>
|
||||
<Stack gap="1" class="flex-1 min-w-0">
|
||||
<Text size="sm" weight="semibold" class="truncate">{{ title }}</Text>
|
||||
<Text v-if="subtitle" tone="muted" size="xs" class="truncate">{{ subtitle }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack v-if="badges.length > 0" direction="row" gap="1" wrap>
|
||||
<Badge
|
||||
v-for="badge in badges"
|
||||
:key="badge.label"
|
||||
:variant="badge.variant"
|
||||
size="sm"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface BadgeItem {
|
||||
label: string
|
||||
variant: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
iconName: string
|
||||
iconTone?: 'primary' | 'success' | 'warning' | 'error' | 'muted'
|
||||
badges?: BadgeItem[]
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
const iconTone = computed(() => props.iconTone || 'primary')
|
||||
const badges = computed(() => props.badges || [])
|
||||
</script>
|
||||
284
webapp/app/components/catalog/CatalogMapView.vue
Normal file
284
webapp/app/components/catalog/CatalogMapView.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<div class="flex gap-4 h-[600px]">
|
||||
<!-- Left Panel -->
|
||||
<Card class="w-80 flex-shrink-0 flex flex-col overflow-hidden">
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="flex-1 px-4 py-3 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
activeTab === tab.id
|
||||
? 'text-primary border-b-2 border-primary bg-primary/5'
|
||||
: 'text-gray-500 hover:text-gray-700 hover:bg-gray-50'
|
||||
]"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span class="ml-1 text-xs text-gray-400">({{ tab.count }})</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
<!-- Hubs Tab -->
|
||||
<template v-if="activeTab === 'hubs'">
|
||||
<CatalogMapItem
|
||||
v-for="hub in hubs"
|
||||
:key="hub.uuid"
|
||||
:title="hub.name || 'Без названия'"
|
||||
:subtitle="hub.country || undefined"
|
||||
icon-name="lucide:warehouse"
|
||||
icon-tone="primary"
|
||||
:badges="hub.distance ? [{ label: hub.distance, variant: 'muted' }] : []"
|
||||
:is-selected="selectedId === hub.uuid"
|
||||
@click="selectLocation(hub, 'hub')"
|
||||
/>
|
||||
<Text v-if="hubs.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||
Нет хабов
|
||||
</Text>
|
||||
</template>
|
||||
|
||||
<!-- Offers Tab -->
|
||||
<template v-if="activeTab === 'offers'">
|
||||
<CatalogMapItem
|
||||
v-for="offer in offers"
|
||||
:key="offer.uuid"
|
||||
:title="offer.title || 'Без названия'"
|
||||
:subtitle="offer.locationName || undefined"
|
||||
icon-name="lucide:package"
|
||||
icon-tone="success"
|
||||
:badges="getOfferBadges(offer)"
|
||||
:is-selected="selectedId === offer.uuid"
|
||||
@click="selectOffer(offer)"
|
||||
/>
|
||||
<Text v-if="offers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||
Нет предложений
|
||||
</Text>
|
||||
</template>
|
||||
|
||||
<!-- Suppliers Tab -->
|
||||
<template v-if="activeTab === 'suppliers'">
|
||||
<CatalogMapItem
|
||||
v-for="supplier in suppliers"
|
||||
:key="supplier.uuid"
|
||||
:title="supplier.name || 'Без названия'"
|
||||
:subtitle="supplier.country || undefined"
|
||||
icon-name="lucide:building-2"
|
||||
icon-tone="warning"
|
||||
:badges="getSupplierBadges(supplier)"
|
||||
:is-selected="selectedId === supplier.uuid"
|
||||
@click="selectSupplier(supplier)"
|
||||
/>
|
||||
<Text v-if="suppliers.length === 0" tone="muted" size="sm" class="text-center py-4">
|
||||
Нет компаний
|
||||
</Text>
|
||||
</template>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Map -->
|
||||
<Card class="flex-1 overflow-hidden p-0">
|
||||
<ClientOnly>
|
||||
<MapboxGlobe
|
||||
ref="mapRef"
|
||||
:locations="mapLocations"
|
||||
:height="600"
|
||||
:selected-location-id="selectedId"
|
||||
@location-click="onMapLocationClick"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Hub {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
distance?: string
|
||||
}
|
||||
|
||||
interface OfferLine {
|
||||
uuid?: string | null
|
||||
productName?: string | null
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
uuid?: string | null
|
||||
title?: string | null
|
||||
locationName?: string | null
|
||||
status?: string | null
|
||||
validUntil?: string | null
|
||||
lines?: (OfferLine | null)[] | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
}
|
||||
|
||||
interface Supplier {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
offersCount?: number | null
|
||||
isVerified?: boolean | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
hubs: Hub[]
|
||||
offers: Offer[]
|
||||
suppliers: Supplier[]
|
||||
}>()
|
||||
|
||||
type TabId = 'hubs' | 'offers' | 'suppliers'
|
||||
|
||||
const activeTab = ref<TabId>('hubs')
|
||||
const selectedId = ref<string | null>(null)
|
||||
const mapRef = ref<any>(null)
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'hubs' as const, label: 'Хабы', count: props.hubs.length },
|
||||
{ id: 'offers' as const, label: 'Офферы', count: props.offers.length },
|
||||
{ id: 'suppliers' as const, label: 'Компании', count: props.suppliers.length }
|
||||
])
|
||||
|
||||
// Combine all locations for the map (only those with coordinates)
|
||||
const mapLocations = computed(() => {
|
||||
const locations: Array<{
|
||||
uuid: string | null | undefined
|
||||
name: string | null | undefined
|
||||
latitude: number | null | undefined
|
||||
longitude: number | null | undefined
|
||||
country: string | null | undefined
|
||||
}> = []
|
||||
|
||||
props.hubs.forEach(hub => {
|
||||
if (hub.latitude && hub.longitude) {
|
||||
locations.push({
|
||||
uuid: hub.uuid,
|
||||
name: hub.name,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
country: hub.country
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
props.offers.forEach(offer => {
|
||||
if (offer.latitude && offer.longitude) {
|
||||
locations.push({
|
||||
uuid: offer.uuid,
|
||||
name: offer.title,
|
||||
latitude: offer.latitude,
|
||||
longitude: offer.longitude,
|
||||
country: offer.locationName
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
props.suppliers.forEach(supplier => {
|
||||
if (supplier.latitude && supplier.longitude) {
|
||||
locations.push({
|
||||
uuid: supplier.uuid,
|
||||
name: supplier.name,
|
||||
latitude: supplier.latitude,
|
||||
longitude: supplier.longitude,
|
||||
country: supplier.country
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return locations
|
||||
})
|
||||
|
||||
const getOfferBadges = (offer: Offer) => {
|
||||
const badges: Array<{ label: string; variant: string }> = []
|
||||
|
||||
if (offer.status) {
|
||||
const statusMap: Record<string, { label: string; variant: string }> = {
|
||||
ACTIVE: { label: 'Активно', variant: 'success' },
|
||||
DRAFT: { label: 'Черновик', variant: 'warning' },
|
||||
CANCELLED: { label: 'Отменено', variant: 'error' },
|
||||
CLOSED: { label: 'Закрыто', variant: 'muted' }
|
||||
}
|
||||
const statusInfo = statusMap[offer.status]
|
||||
if (statusInfo) {
|
||||
badges.push(statusInfo)
|
||||
}
|
||||
}
|
||||
|
||||
const linesCount = offer.lines?.length || 0
|
||||
if (linesCount > 0) {
|
||||
badges.push({ label: `${linesCount} поз.`, variant: 'muted' })
|
||||
}
|
||||
|
||||
return badges
|
||||
}
|
||||
|
||||
const getSupplierBadges = (supplier: Supplier) => {
|
||||
const badges: Array<{ label: string; variant: string }> = []
|
||||
|
||||
if (supplier.isVerified) {
|
||||
badges.push({ label: 'Проверен', variant: 'success' })
|
||||
}
|
||||
|
||||
if (supplier.offersCount && supplier.offersCount > 0) {
|
||||
badges.push({ label: `${supplier.offersCount} офф.`, variant: 'muted' })
|
||||
}
|
||||
|
||||
return badges
|
||||
}
|
||||
|
||||
const selectLocation = (hub: Hub, _type: string) => {
|
||||
selectedId.value = hub.uuid || null
|
||||
|
||||
if (hub.latitude && hub.longitude && mapRef.value) {
|
||||
mapRef.value.flyToLocation({
|
||||
uuid: hub.uuid,
|
||||
name: hub.name,
|
||||
latitude: hub.latitude,
|
||||
longitude: hub.longitude,
|
||||
country: hub.country
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectOffer = (offer: Offer) => {
|
||||
selectedId.value = offer.uuid || null
|
||||
|
||||
if (offer.latitude && offer.longitude && mapRef.value) {
|
||||
mapRef.value.flyToLocation({
|
||||
uuid: offer.uuid,
|
||||
name: offer.title,
|
||||
latitude: offer.latitude,
|
||||
longitude: offer.longitude,
|
||||
country: offer.locationName
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const selectSupplier = (supplier: Supplier) => {
|
||||
selectedId.value = supplier.uuid || null
|
||||
|
||||
if (supplier.latitude && supplier.longitude && mapRef.value) {
|
||||
mapRef.value.flyToLocation({
|
||||
uuid: supplier.uuid,
|
||||
name: supplier.name,
|
||||
latitude: supplier.latitude,
|
||||
longitude: supplier.longitude,
|
||||
country: supplier.country
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const onMapLocationClick = (location: any) => {
|
||||
selectedId.value = location.uuid || null
|
||||
}
|
||||
</script>
|
||||
113
webapp/app/components/catalog/CatalogOffersSection.vue
Normal file
113
webapp/app/components/catalog/CatalogOffersSection.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">Предложения</Heading>
|
||||
<NuxtLink :to="localePath('/catalog/offers')">
|
||||
<Button variant="secondary" size="small">Смотреть все</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="offer in displayedOffers"
|
||||
:key="offer.uuid"
|
||||
padding="sm"
|
||||
tone="muted"
|
||||
interactive
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ offer.title }}</Text>
|
||||
<Text tone="muted">{{ offer.locationName || 'Локация не указана' }}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<Pill
|
||||
v-for="line in (offer.lines || []).slice(0, 3)"
|
||||
:key="line?.uuid"
|
||||
variant="outline"
|
||||
>
|
||||
{{ line?.productName }}
|
||||
</Pill>
|
||||
<Pill v-if="(offer.lines?.length || 0) > 3" variant="outline">
|
||||
+{{ (offer.lines?.length || 0) - 3 }}
|
||||
</Pill>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Badge :variant="getStatusVariant(offer.status)">
|
||||
{{ getStatusLabel(offer.status) }}
|
||||
</Badge>
|
||||
<Text v-if="offer.validUntil" tone="muted" size="sm">
|
||||
до {{ formatDate(offer.validUntil) }}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Stack v-if="offers.length === 0" align="center" gap="2">
|
||||
<Text tone="muted">Нет активных предложений</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface OfferLine {
|
||||
uuid?: string | null
|
||||
productName?: string | null
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
uuid?: string | null
|
||||
title?: string | null
|
||||
locationName?: string | null
|
||||
status?: string | null
|
||||
validUntil?: string | null
|
||||
lines?: (OfferLine | null)[] | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
offers: Offer[]
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const displayedOffers = computed(() => props.offers.slice(0, 6))
|
||||
|
||||
const getStatusVariant = (status: string | null | undefined) => {
|
||||
const variants: Record<string, string> = {
|
||||
ACTIVE: 'success',
|
||||
DRAFT: 'warning',
|
||||
CANCELLED: 'error',
|
||||
CLOSED: 'muted'
|
||||
}
|
||||
return variants[status || ''] || 'muted'
|
||||
}
|
||||
|
||||
const getStatusLabel = (status: string | null | undefined) => {
|
||||
const labels: Record<string, string> = {
|
||||
ACTIVE: 'Активно',
|
||||
DRAFT: 'Черновик',
|
||||
CANCELLED: 'Отменено',
|
||||
CLOSED: 'Закрыто'
|
||||
}
|
||||
return labels[status || ''] || status || 'Неизвестно'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat('ru', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
}).format(new Date(dateStr))
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
</script>
|
||||
69
webapp/app/components/catalog/CatalogSuppliersSection.vue
Normal file
69
webapp/app/components/catalog/CatalogSuppliersSection.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">Проверенные компании</Heading>
|
||||
<NuxtLink :to="localePath('/catalog/suppliers')">
|
||||
<Button variant="secondary" size="small">Смотреть все</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="supplier in displayedSuppliers"
|
||||
:key="supplier.uuid"
|
||||
padding="sm"
|
||||
tone="muted"
|
||||
interactive
|
||||
>
|
||||
<Stack gap="3">
|
||||
<Stack direction="row" align="start" gap="3">
|
||||
<IconCircle tone="primary">
|
||||
{{ supplier.name?.charAt(0) }}
|
||||
</IconCircle>
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ supplier.name }}</Text>
|
||||
<Text tone="muted">{{ supplier.country || 'Страна не указана' }}</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text tone="muted" size="sm">{{ supplier.offersCount || 0 }} предложений</Text>
|
||||
<Badge v-if="supplier.isVerified" variant="success">
|
||||
Проверен
|
||||
</Badge>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Stack v-if="suppliers.length === 0" align="center" gap="2">
|
||||
<Text tone="muted">Нет зарегистрированных компаний</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Supplier {
|
||||
uuid?: string | null
|
||||
name?: string | null
|
||||
country?: string | null
|
||||
offersCount?: number | null
|
||||
isVerified?: boolean | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
suppliers: Supplier[]
|
||||
}>()
|
||||
|
||||
const localePath = useLocalePath()
|
||||
|
||||
const displayedSuppliers = computed(() => {
|
||||
return [...props.suppliers]
|
||||
.sort((a, b) => (b.offersCount || 0) - (a.offersCount || 0))
|
||||
.slice(0, 6)
|
||||
})
|
||||
</script>
|
||||
@@ -1,140 +1,71 @@
|
||||
<template>
|
||||
<Stack gap="8">
|
||||
<Section variant="hero">
|
||||
<Stack gap="2">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="1" tone="inverse">Каталог Optovia</Heading>
|
||||
<Text tone="inverse" size="base">
|
||||
Логистика, поставщики и ассортимент в одном окне. Смотрите ключевые направления,
|
||||
затем переходите в нужный раздел.
|
||||
</Text>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
:variant="viewMode === 'list' ? 'primary' : 'secondary'"
|
||||
@click="viewMode = 'list'"
|
||||
>
|
||||
<Icon name="lucide:list" size="18" class="mr-2" />
|
||||
Список
|
||||
</Button>
|
||||
<Button
|
||||
:variant="viewMode === 'map' ? 'primary' : 'secondary'"
|
||||
@click="viewMode = 'map'"
|
||||
>
|
||||
<Icon name="lucide:map" size="18" class="mr-2" />
|
||||
Карта
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</Section>
|
||||
|
||||
<Section variant="plain" paddingY="md">
|
||||
<Card v-if="isLoading">
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">Загружаем данные каталога...</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Stack v-else gap="8">
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Heading :level="2">Ключевые хабы и страны</Heading>
|
||||
<Stack direction="row" gap="2">
|
||||
<Button variant="primary">Список</Button>
|
||||
<NuxtLink :to="localePath('/catalog/map')">
|
||||
<Button variant="secondary">Карта</Button>
|
||||
</NuxtLink>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="location in topLocations"
|
||||
:key="location.uuid"
|
||||
padding="sm"
|
||||
tone="muted"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ location.name }}</Text>
|
||||
<Text tone="muted">{{ location.country }}</Text>
|
||||
</Stack>
|
||||
<Text tone="muted" size="base">{{ location.distance }}</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">Проверенные компании</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="supplier in topSuppliers"
|
||||
:key="supplier.uuid"
|
||||
padding="sm"
|
||||
tone="muted"
|
||||
interactive
|
||||
>
|
||||
<Stack gap="3">
|
||||
<IconCircle tone="primary">
|
||||
{{ supplier.name?.charAt(0) }}
|
||||
</IconCircle>
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ supplier.name }}</Text>
|
||||
<Text tone="muted">{{ supplier.country || 'Страна не указана' }}</Text>
|
||||
<Text tone="muted" size="base">{{ supplier.offersCount || 0 }} предложений</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Stack gap="4">
|
||||
<Heading :level="2">Категории и топовые SKU</Heading>
|
||||
<Grid :cols="1" :md="2" :lg="3" :gap="4">
|
||||
<Card
|
||||
v-for="category in compactCategories"
|
||||
:key="category.name"
|
||||
padding="sm"
|
||||
tone="muted"
|
||||
>
|
||||
<Stack gap="2">
|
||||
<Stack gap="1">
|
||||
<Text size="base" weight="semibold">{{ category.name }}</Text>
|
||||
<Text tone="muted">{{ category.items.length }} позиций</Text>
|
||||
</Stack>
|
||||
<Stack direction="row" gap="2" wrap>
|
||||
<Pill v-for="item in category.items" :key="item.uuid" variant="outline">
|
||||
{{ item.name }}
|
||||
</Pill>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Card>
|
||||
<!-- Loading state -->
|
||||
<Section v-if="isLoading" variant="plain" paddingY="md">
|
||||
<Card>
|
||||
<Stack align="center" justify="center" gap="3">
|
||||
<Spinner />
|
||||
<Text tone="muted">Загружаем данные каталога...</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<!-- Режим списка -->
|
||||
<template v-else-if="viewMode === 'list'">
|
||||
<CatalogHubsSection :hubs="locations" />
|
||||
<CatalogOffersSection :offers="offers" />
|
||||
<CatalogSuppliersSection :suppliers="suppliers" />
|
||||
</template>
|
||||
|
||||
<!-- Режим карты -->
|
||||
<template v-else>
|
||||
<CatalogMapView
|
||||
:hubs="locations"
|
||||
:offers="offers"
|
||||
:suppliers="suppliers"
|
||||
/>
|
||||
</template>
|
||||
</Stack>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GetProductsDocument, GetLogisticsNodesDocument, GetSuppliersDocument } from '~/composables/graphql/public/exchange-generated'
|
||||
import {
|
||||
GetLogisticsNodesDocument,
|
||||
GetSuppliersDocument,
|
||||
GetOffersDocument
|
||||
} from '~/composables/graphql/public/exchange-generated'
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { execute } = useGraphQL()
|
||||
|
||||
const viewMode = ref<'list' | 'map'>('list')
|
||||
const isLoading = ref(true)
|
||||
const suppliers = ref([])
|
||||
const products = ref([])
|
||||
const locations = ref([])
|
||||
const suppliers = ref<any[]>([])
|
||||
const offers = ref<any[]>([])
|
||||
const locations = ref<any[]>([])
|
||||
|
||||
const productCategories = computed(() => [...new Set(products.value.map(p => p.categoryName).filter(Boolean))])
|
||||
|
||||
const topSuppliers = computed(() => {
|
||||
return [...suppliers.value]
|
||||
.sort((a, b) => (b.offersCount || 0) - (a.offersCount || 0))
|
||||
.slice(0, 6)
|
||||
})
|
||||
|
||||
const topLocations = computed(() => locations.value.slice(0, 6))
|
||||
|
||||
const compactCategories = computed(() => {
|
||||
const categories = productCategories.value.slice(0, 6)
|
||||
return categories.map(cat => ({
|
||||
name: cat,
|
||||
items: products.value.filter(p => p.categoryName === cat).slice(0, 5),
|
||||
}))
|
||||
})
|
||||
|
||||
const calculateDistance = (lat, lng) => {
|
||||
const calculateDistance = (lat: number, lng: number) => {
|
||||
const moscowLat = 55.76
|
||||
const moscowLng = 37.64
|
||||
const distance = Math.sqrt(Math.pow(lat - moscowLat, 2) + Math.pow(lng - moscowLng, 2)) * 111
|
||||
@@ -143,15 +74,15 @@ const calculateDistance = (lat, lng) => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [suppliersResult, productsResult, locationsResult] = await Promise.all([
|
||||
const [suppliersResult, offersResult, locationsResult] = await Promise.all([
|
||||
execute(GetSuppliersDocument, {}, 'public', 'exchange').catch(() => ({ getSuppliers: [] })),
|
||||
execute(GetProductsDocument, {}, 'public', 'exchange').catch(() => ({ getProducts: [] })),
|
||||
execute(GetOffersDocument, {}, 'public', 'exchange').catch(() => ({ getOffers: [] })),
|
||||
execute(GetLogisticsNodesDocument, {}, 'public', 'exchange').catch(() => ({ getLogisticsNodes: [] })),
|
||||
])
|
||||
|
||||
suppliers.value = suppliersResult.getSuppliers || []
|
||||
products.value = productsResult.getProducts || []
|
||||
locations.value = (locationsResult.getLogisticsNodes || []).map(location => ({
|
||||
offers.value = offersResult.getOffers || []
|
||||
locations.value = (locationsResult.getLogisticsNodes || []).map((location: any) => ({
|
||||
...location,
|
||||
distance:
|
||||
location?.latitude && location?.longitude
|
||||
|
||||
Reference in New Issue
Block a user