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:
Ruslan Bakiev
2025-12-08 19:06:50 +07:00
parent 6d6a300481
commit 3596c3757b
6 changed files with 646 additions and 123 deletions

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

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

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

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

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

View File

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