Refactor catalog layout: replace Teleport with provide/inject
All checks were successful
Build Docker Image / build (push) Successful in 4m1s

- Create useCatalogLayout composable for data transfer from pages to layout
- Layout now owns SearchBar and CatalogMap components directly
- Pages provide data via provideCatalogLayout()
- Fixes navigation glitches (multiple SearchBars) when switching tabs
- Support custom subNavItems for clientarea pages
- Unify 6 pages to use catalog layout:
  - catalog/offers, suppliers, hubs
  - clientarea/orders, addresses, offers
This commit is contained in:
Ruslan Bakiev
2026-01-15 10:49:40 +07:00
parent 4bd5b882e0
commit 7ea96a97b3
8 changed files with 828 additions and 387 deletions

View File

@@ -1,76 +1,5 @@
<template>
<div>
<!-- SearchBar teleported to layout -->
<Teleport to="#catalog-searchbar">
<div class="px-4 lg:px-6 py-2">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="activeFilterBadges"
:displayed-count="displayItems.length"
:total-count="total"
@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('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>
<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>
</CatalogSearchBar>
</div>
</Teleport>
<!-- Map teleported to layout -->
<Teleport to="#catalog-map">
<div class="h-full w-full relative">
<!-- Search with map checkbox -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="hubs-map"
:items="useServerClustering ? [] : itemsWithCoords"
:clustered-points="useServerClustering ? clusteredNodes : []"
:use-server-clustering="useServerClustering"
point-color="#10b981"
:hovered-item-id="hoveredHubId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
</Teleport>
<!-- List content -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Card padding="lg">
@@ -117,7 +46,9 @@
</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'
@@ -147,9 +78,6 @@ const hoveredHubId = ref<string>()
// Search bar
const searchQuery = ref('')
// Map ref
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
// Server-side clustering
const useServerClustering = true
const { clusteredNodes, fetchClusters } = useClusteredNodes()
@@ -231,10 +159,6 @@ const onSearch = () => {
const onSelectHub = (hub: any) => {
selectedHubId.value = hub.uuid
// Fly to hub on map
if (hub.latitude && hub.longitude) {
mapRef.value?.flyTo(Number(hub.latitude), Number(hub.longitude), 8)
}
}
// Handle selection from map
@@ -258,6 +182,65 @@ 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,60 +1,5 @@
<template>
<div>
<!-- SearchBar teleported to layout -->
<Teleport to="#catalog-searchbar">
<div class="px-4 lg:px-6 py-2">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="activeFilterBadges"
:displayed-count="displayItems.length"
:total-count="total"
@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>
</div>
</Teleport>
<!-- Map teleported to layout -->
<Teleport to="#catalog-map">
<div class="h-full w-full relative">
<!-- Search with map checkbox -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="offers-map"
:items="itemsWithCoords"
point-color="#f59e0b"
:hovered-item-id="hoveredOfferId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
</Teleport>
<!-- List content -->
<div v-if="isLoading || productsLoading" class="flex items-center justify-center py-8">
<Card padding="lg">
@@ -98,7 +43,9 @@
</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'
@@ -133,9 +80,6 @@ const hoveredOfferId = ref<string>()
// Search bar
const searchQuery = ref('')
// Map ref
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
@@ -218,10 +162,6 @@ const onSearch = () => {
const onSelectOffer = (offer: any) => {
selectedOfferId.value = offer.uuid
// Fly to offer on map
if (offer.locationLatitude && offer.locationLongitude) {
mapRef.value?.flyTo(Number(offer.locationLatitude), Number(offer.locationLongitude), 8)
}
}
// Handle selection from map
@@ -232,6 +172,46 @@ const onMapSelect = (uuid: string) => {
}
}
// 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,41 +1,5 @@
<template>
<div>
<!-- SearchBar teleported to layout -->
<Teleport to="#catalog-searchbar">
<div class="px-4 lg:px-6 py-2">
<CatalogSearchBar
v-model:search-query="searchQuery"
:active-filters="[]"
:displayed-count="displayItems.length"
:total-count="total"
@search="onSearch"
/>
</div>
</Teleport>
<!-- Map teleported to layout -->
<Teleport to="#catalog-map">
<div class="h-full w-full relative">
<!-- Search with map checkbox -->
<label class="absolute top-4 left-4 z-10 bg-white/90 backdrop-blur px-3 py-2 rounded-lg shadow flex items-center gap-2 cursor-pointer">
<input type="checkbox" v-model="searchWithMap" class="checkbox checkbox-sm" />
<span class="text-sm">{{ t('catalogMap.searchWithMap') }}</span>
</label>
<ClientOnly>
<CatalogMap
ref="mapRef"
map-id="suppliers-map"
:items="itemsWithCoords"
point-color="#3b82f6"
:hovered-item-id="hoveredSupplierId"
:hovered-item="hoveredItem"
@select-item="onMapSelect"
@bounds-change="onBoundsChange"
/>
</ClientOnly>
</div>
</Teleport>
<!-- List content -->
<div v-if="isLoading" class="flex items-center justify-center py-8">
<Card padding="lg">
@@ -80,6 +44,7 @@
<script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
import { provideCatalogLayout } from '~/composables/useCatalogLayout'
definePageMeta({
layout: 'catalog'
@@ -104,9 +69,6 @@ const hoveredSupplierId = ref<string>()
// Search bar
const searchQuery = ref('')
// Map ref
const mapRef = ref<{ flyTo: (lat: number, lng: number, zoom?: number) => void } | null>(null)
// Search with map checkbox
const searchWithMap = ref(false)
const currentBounds = ref<MapBounds | null>(null)
@@ -155,12 +117,13 @@ 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
// Fly to supplier on map
if (supplier.latitude && supplier.longitude) {
mapRef.value?.flyTo(Number(supplier.latitude), Number(supplier.longitude), 8)
}
}
// Handle selection from map
@@ -171,6 +134,24 @@ const onMapSelect = (uuid: string) => {
}
}
// 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(() => ({