Refactor: use topnav layout + CatalogPage component
All checks were successful
Build Docker Image / build (push) Successful in 4m11s
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:
@@ -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(() => ({
|
||||
|
||||
@@ -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()])
|
||||
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
Reference in New Issue
Block a user