Refactor: use topnav layout + CatalogPage component
All checks were successful
Build Docker Image / build (push) Successful in 4m11s

- Remove catalog.vue layout and useCatalogLayout.ts (broken provide/inject)
- All catalog/clientarea list pages now use topnav layout
- Pages use CatalogPage component for SearchBar + Map functionality
- Clean architecture: layout handles nav, component handles features
This commit is contained in:
Ruslan Bakiev
2026-01-15 11:18:57 +07:00
parent 7ea96a97b3
commit 03485b77a5
8 changed files with 401 additions and 1092 deletions

View File

@@ -1,57 +1,90 @@
<template>
<div>
<!-- List content -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</div>
<template v-else>
<Stack gap="3">
<div
v-for="item in displayItems"
:key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedHubId }"
@click="onSelectHub(item)"
@mouseenter="hoveredHubId = item.uuid"
@mouseleave="hoveredHubId = undefined"
<CatalogPage
:items="displayItems"
:map-items="itemsWithCoords"
:loading="isLoading"
with-map
use-server-clustering
map-id="hubs-map"
point-color="#10b981"
:selected-id="selectedHubId"
:hovered-id="hoveredHubId"
:total-count="total"
@select="onSelectHub"
@update:hovered-id="hoveredHubId = $event"
>
<template #searchBar="{ displayedCount, totalCount }">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="activeFilterBadges"
:displayed-count="displayedCount"
:total-count="totalCount"
@remove-filter="onRemoveFilter"
@search="onSearch"
>
<template v-for="country in getCountryForHub(item)" :key="country.name">
<Text v-if="isFirstInCountry(item)" weight="semibold" class="mb-2">{{ country.name }}</Text>
<template #filters>
<div class="p-2 space-y-3">
<!-- Transport filter -->
<div>
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('catalogHubsSection.filters.transport') }}</div>
<ul class="menu menu-compact">
<li v-for="filter in filters" :key="filter.id">
<a
:class="{ 'active': selectedFilter === filter.id }"
@click="selectedFilter = filter.id"
>{{ filter.label }}</a>
</li>
</ul>
</div>
<div class="divider my-0"></div>
<!-- Country filter -->
<div>
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('catalogHubsSection.filters.country') }}</div>
<ul class="menu menu-compact max-h-48 overflow-y-auto">
<li v-for="filter in countryFilters" :key="filter.id">
<a
:class="{ 'active': selectedCountry === filter.id }"
@click="selectedCountry = filter.id"
>{{ filter.label }}</a>
</li>
</ul>
</div>
</div>
</template>
<HubCard :hub="item" />
</div>
</Stack>
</CatalogSearchBar>
</template>
<PaginationLoadMore
v-if="displayItems.length > 0"
:shown="displayItems.length"
:total="total"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
hide-counter
@load-more="loadMore"
class="mt-4"
/>
<template #card="{ item }">
<template v-for="country in getCountryForHub(item)" :key="country.name">
<Text v-if="isFirstInCountry(item)" weight="semibold" class="mb-2">{{ country.name }}</Text>
</template>
<HubCard :hub="item" />
</template>
<Stack v-if="displayItems.length === 0" align="center" gap="2" class="py-8">
<template #pagination>
<PaginationLoadMore
v-if="displayItems.length > 0"
:shown="displayItems.length"
:total="total"
:can-load-more="canLoadMore"
:loading="isLoadingMore"
hide-counter
@load-more="loadMore"
class="mt-4"
/>
</template>
<template #empty>
<Text tone="muted">{{ t('catalogHubsSection.empty.no_hubs') }}</Text>
</Stack>
</template>
</div>
</template>
</CatalogPage>
</template>
<script setup lang="ts">
import { h, defineComponent } from 'vue'
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import { provideCatalogLayout } from '~/composables/useCatalogLayout'
definePageMeta({
layout: 'catalog'
layout: 'topnav'
})
const { t } = useI18n()
@@ -78,21 +111,10 @@ const hoveredHubId = ref<string>()
// Search bar
const searchQuery = ref('')
// Server-side clustering
const useServerClustering = true
const { clusteredNodes, fetchClusters } = useClusteredNodes()
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
if (useServerClustering) {
fetchClusters(bounds)
}
}
// Filter items with valid coordinates for map
const itemsWithCoords = computed(() =>
items.value.filter(item =>
@@ -121,14 +143,6 @@ const displayItems = computed(() => {
})
})
// Hovered item with coordinates for map highlight
const hoveredItem = computed(() => {
if (!hoveredHubId.value) return null
const item = items.value.find(i => i.uuid === hoveredHubId.value)
if (!item?.latitude || !item?.longitude) return null
return { latitude: Number(item.latitude), longitude: Number(item.longitude) }
})
// Active filter badges (non-default filters shown as badges)
const activeFilterBadges = computed(() => {
const badges: { id: string; label: string }[] = []
@@ -161,14 +175,6 @@ const onSelectHub = (hub: any) => {
selectedHubId.value = hub.uuid
}
// Handle selection from map
const onMapSelect = (uuid: string) => {
const hub = items.value.find(i => i.uuid === uuid)
if (hub) {
selectedHubId.value = uuid
}
}
// Helper to get country for hub
const getCountryForHub = (hub: any) => {
return itemsByCountry.value.filter(c => c.hubs.some(h => h.uuid === hub.uuid))
@@ -182,65 +188,6 @@ const isFirstInCountry = (hub: any) => {
return false
}
// Filter component for the layout
const HubsFilterComponent = defineComponent({
setup() {
return () => h('div', { class: 'p-2 space-y-3' }, [
// Transport filter
h('div', {}, [
h('div', { class: 'text-xs font-semibold mb-1 text-base-content/70' }, t('catalogHubsSection.filters.transport')),
h('ul', { class: 'menu menu-compact' },
filters.value.map(filter =>
h('li', { key: filter.id },
h('a', {
class: selectedFilter.value === filter.id ? 'active' : '',
onClick: () => { selectedFilter.value = filter.id }
}, filter.label)
)
)
)
]),
// Divider
h('div', { class: 'divider my-0' }),
// Country filter
h('div', {}, [
h('div', { class: 'text-xs font-semibold mb-1 text-base-content/70' }, t('catalogHubsSection.filters.country')),
h('ul', { class: 'menu menu-compact max-h-48 overflow-y-auto' },
countryFilters.value.map(filter =>
h('li', { key: filter.id },
h('a', {
class: selectedCountry.value === filter.id ? 'active' : '',
onClick: () => { selectedCountry.value = filter.id }
}, filter.label)
)
)
)
])
])
}
})
// Provide data to layout
provideCatalogLayout({
searchQuery,
activeFilters: activeFilterBadges,
displayedCount: computed(() => displayItems.value.length),
totalCount: computed(() => total.value),
onSearch,
onRemoveFilter,
filterComponent: HubsFilterComponent,
mapItems: itemsWithCoords,
mapId: 'hubs-map',
pointColor: '#10b981',
hoveredItemId: hoveredHubId,
hoveredItem,
onMapSelect,
onBoundsChange,
searchWithMap,
useServerClustering,
clusteredNodes
})
await init()
useHead(() => ({

View File

@@ -1,29 +1,49 @@
<template>
<div>
<!-- List content -->
<div v-if="isLoading || productsLoading" class="flex items-center justify-center py-8">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</div>
<CatalogPage
:items="displayItems"
:map-items="itemsWithCoords"
:loading="isLoading || productsLoading"
with-map
map-id="offers-map"
point-color="#f59e0b"
:selected-id="selectedOfferId"
:hovered-id="hoveredOfferId"
:total-count="total"
@select="onSelectOffer"
@update:hovered-id="hoveredOfferId = $event"
>
<template #searchBar="{ displayedCount, totalCount }">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="activeFilterBadges"
:displayed-count="displayedCount"
:total-count="totalCount"
@remove-filter="onRemoveFilter"
@search="onSearch"
>
<template #filters>
<div class="p-2 space-y-3">
<div>
<div class="text-xs font-semibold mb-1 text-base-content/70">{{ t('catalogOffersSection.filters.product') }}</div>
<ul class="menu menu-compact max-h-48 overflow-y-auto">
<li v-for="filter in productFilters" :key="filter.id">
<a
:class="{ 'active': (selectedProductUuid === filter.id || (!selectedProductUuid && filter.id === 'all')) }"
@click="onProductFilterChange(filter.id)"
>{{ filter.label }}</a>
</li>
</ul>
</div>
</div>
</template>
</CatalogSearchBar>
</template>
<template v-else>
<Stack gap="3">
<div
v-for="item in displayItems"
:key="item.uuid"
:class="{ 'ring-2 ring-primary rounded-lg': item.uuid === selectedOfferId }"
@click="onSelectOffer(item)"
@mouseenter="hoveredOfferId = item.uuid"
@mouseleave="hoveredOfferId = undefined"
>
<OfferCard :offer="item" />
</div>
</Stack>
<template #card="{ item }">
<OfferCard :offer="item" />
</template>
<template #pagination>
<PaginationLoadMore
v-if="displayItems.length > 0"
:shown="displayItems.length"
@@ -34,21 +54,19 @@
@load-more="loadMore"
class="mt-4"
/>
<Stack v-if="displayItems.length === 0" align="center" gap="2" class="py-8">
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
</Stack>
</template>
</div>
<template #empty>
<Text tone="muted">{{ t('catalogOffersSection.empty.no_offers') }}</Text>
</template>
</CatalogPage>
</template>
<script setup lang="ts">
import { h, defineComponent } from 'vue'
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import { provideCatalogLayout } from '~/composables/useCatalogLayout'
definePageMeta({
layout: 'catalog'
layout: 'topnav'
})
const { t } = useI18n()
@@ -84,10 +102,6 @@ const searchQuery = ref('')
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
}
// Map items with correct coordinate field names
const itemsWithCoords = computed(() =>
items.value.filter(item =>
@@ -115,14 +129,6 @@ const displayItems = computed(() => {
})
})
// Hovered item with coordinates for map highlight
const hoveredItem = computed(() => {
if (!hoveredOfferId.value) return null
const item = items.value.find(i => i.uuid === hoveredOfferId.value)
if (!item?.locationLatitude || !item?.locationLongitude) return null
return { latitude: Number(item.locationLatitude), longitude: Number(item.locationLongitude) }
})
// Product filter options
const productFilters = computed(() => {
const all = [{ id: 'all', label: t('catalogOffersSection.filters.all_products') }]
@@ -164,54 +170,6 @@ const onSelectOffer = (offer: any) => {
selectedOfferId.value = offer.uuid
}
// Handle selection from map
const onMapSelect = (uuid: string) => {
const offer = items.value.find(i => i.uuid === uuid)
if (offer) {
selectedOfferId.value = uuid
}
}
// Filter component for the layout
const OffersFilterComponent = defineComponent({
setup() {
return () => h('div', { class: 'p-2 space-y-3' }, [
h('div', {}, [
h('div', { class: 'text-xs font-semibold mb-1 text-base-content/70' }, t('catalogOffersSection.filters.product')),
h('ul', { class: 'menu menu-compact max-h-48 overflow-y-auto' },
productFilters.value.map(filter =>
h('li', { key: filter.id },
h('a', {
class: (selectedProductUuid.value === filter.id || (!selectedProductUuid.value && filter.id === 'all')) ? 'active' : '',
onClick: () => onProductFilterChange(filter.id)
}, filter.label)
)
)
)
])
])
}
})
// Provide data to layout
provideCatalogLayout({
searchQuery,
activeFilters: activeFilterBadges,
displayedCount: computed(() => displayItems.value.length),
totalCount: computed(() => total.value),
onSearch,
onRemoveFilter,
filterComponent: OffersFilterComponent,
mapItems: itemsWithCoords,
mapId: 'offers-map',
pointColor: '#f59e0b',
hoveredItemId: hoveredOfferId,
hoveredItem,
onMapSelect,
onBoundsChange,
searchWithMap
})
// Initialize
await Promise.all([initProducts(), initOffers()])

View File

@@ -1,29 +1,32 @@
<template>
<div>
<!-- List content -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Card padding="lg">
<Stack align="center" justify="center" gap="3">
<Spinner />
<Text tone="muted">{{ t('catalogLanding.states.loading') }}</Text>
</Stack>
</Card>
</div>
<CatalogPage
:items="displayItems"
:map-items="itemsWithCoords"
:loading="isLoading"
with-map
map-id="suppliers-map"
point-color="#3b82f6"
:selected-id="selectedSupplierId"
:hovered-id="hoveredSupplierId"
:total-count="total"
@select="onSelectSupplier"
@update:hovered-id="hoveredSupplierId = $event"
>
<template #searchBar="{ displayedCount, totalCount }">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="[]"
:displayed-count="displayedCount"
:total-count="totalCount"
@search="onSearch"
/>
</template>
<template v-else>
<Stack gap="3">
<div
v-for="item in displayItems"
:key="item.uuid || item.teamUuid"
:class="{ 'ring-2 ring-primary rounded-lg': (item.uuid || item.teamUuid) === selectedSupplierId }"
@click="onSelectSupplier(item)"
@mouseenter="hoveredSupplierId = item.uuid || item.teamUuid"
@mouseleave="hoveredSupplierId = undefined"
>
<SupplierCard :supplier="item" />
</div>
</Stack>
<template #card="{ item }">
<SupplierCard :supplier="item" />
</template>
<template #pagination>
<PaginationLoadMore
v-if="displayItems.length > 0"
:shown="displayItems.length"
@@ -34,20 +37,19 @@
@load-more="loadMore"
class="mt-4"
/>
<Stack v-if="displayItems.length === 0" align="center" gap="2" class="py-8">
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
</Stack>
</template>
</div>
<template #empty>
<Text tone="muted">{{ t('catalogSuppliersSection.empty.no_suppliers') }}</Text>
</template>
</CatalogPage>
</template>
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import { provideCatalogLayout } from '~/composables/useCatalogLayout'
definePageMeta({
layout: 'catalog'
layout: 'topnav'
})
const { t } = useI18n()
@@ -73,10 +75,6 @@ const searchQuery = ref('')
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
const onBoundsChange = (bounds: MapBounds) => {
currentBounds.value = bounds
}
// Filter items with valid coordinates for map
const itemsWithCoords = computed(() =>
items.value.filter(item =>
@@ -104,54 +102,15 @@ const displayItems = computed(() => {
})
})
// Hovered item with coordinates for map highlight
const hoveredItem = computed(() => {
if (!hoveredSupplierId.value) return null
const item = items.value.find(i => (i.uuid || i.teamUuid) === hoveredSupplierId.value)
if (!item?.latitude || !item?.longitude) return null
return { latitude: Number(item.latitude), longitude: Number(item.longitude) }
})
// Search handler (for future use)
const onSearch = () => {
// TODO: Implement search
}
// No filters for suppliers
const onRemoveFilter = (_id: string) => {
// No filters to remove
}
const onSelectSupplier = (supplier: any) => {
selectedSupplierId.value = supplier.uuid || supplier.teamUuid
}
// Handle selection from map
const onMapSelect = (uuid: string) => {
const supplier = items.value.find(i => (i.uuid || i.teamUuid) === uuid)
if (supplier) {
selectedSupplierId.value = uuid
}
}
// Provide data to layout (no filter component for suppliers)
provideCatalogLayout({
searchQuery,
activeFilters: ref([]),
displayedCount: computed(() => displayItems.value.length),
totalCount: computed(() => total.value),
onSearch,
onRemoveFilter,
mapItems: itemsWithCoords,
mapId: 'suppliers-map',
pointColor: '#3b82f6',
hoveredItemId: hoveredSupplierId,
hoveredItem,
onMapSelect,
onBoundsChange,
searchWithMap
})
await init()
useHead(() => ({