Simplify catalog UI - remove chips, add drawer for list
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
- Remove product/hub chips from QuoteForm.vue (duplicate of toggle) - Add drawer state to useCatalogSearch.ts (isDrawerOpen, selectDrawerItem, applyDrawerFilter) - Convert SelectionPanel to drawer with header, scrollable content, and footer - Add "Список" button to CatalogPage.vue to open drawer - Add "Применить фильтр" button in drawer footer - Add slide animations for drawer (left on desktop, up on mobile) - Update translations: catalog.list, catalog.applyFilter
This commit is contained in:
@@ -2,64 +2,6 @@
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3 class="font-semibold text-lg">{{ $t('catalog.quote.title') }}</h3>
|
||||
|
||||
<!-- Product chip -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs text-base-content/70">{{ $t('catalog.filters.product') }}</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="productLabel"
|
||||
class="btn btn-outline btn-sm justify-start gap-2"
|
||||
@click="emit('edit-filter', 'product')"
|
||||
>
|
||||
<Icon name="lucide:package" size="16" />
|
||||
<span class="flex-1 text-left truncate">{{ productLabel }}</span>
|
||||
<span
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
@click.stop="emit('remove-filter', 'product')"
|
||||
>
|
||||
<Icon name="lucide:x" size="14" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-ghost btn-sm justify-start gap-2 border border-dashed border-base-content/30"
|
||||
@click="emit('edit-filter', 'product')"
|
||||
>
|
||||
<Icon name="lucide:plus" size="16" class="text-base-content/50" />
|
||||
<span class="text-base-content/50">{{ $t('catalog.quote.selectProduct') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hub chip -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs text-base-content/70">{{ $t('catalog.filters.hub') }}</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="hubLabel"
|
||||
class="btn btn-outline btn-sm justify-start gap-2"
|
||||
@click="emit('edit-filter', 'hub')"
|
||||
>
|
||||
<Icon name="lucide:map-pin" size="16" />
|
||||
<span class="flex-1 text-left truncate">{{ hubLabel }}</span>
|
||||
<span
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
@click.stop="emit('remove-filter', 'hub')"
|
||||
>
|
||||
<Icon name="lucide:x" size="14" />
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-ghost btn-sm justify-start gap-2 border border-dashed border-base-content/30"
|
||||
@click="emit('edit-filter', 'hub')"
|
||||
>
|
||||
<Icon name="lucide:plus" size="16" class="text-base-content/50" />
|
||||
<span class="text-base-content/50">{{ $t('catalog.quote.selectHub') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quantity input -->
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<template>
|
||||
<MapPanel>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold text-base text-base-content">{{ title }}</h3>
|
||||
<button class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content" @click="emit('close')">
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Header -->
|
||||
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-base text-white">{{ title }}</h3>
|
||||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="closeDrawer">
|
||||
<Icon name="lucide:x" size="16" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -11,83 +12,96 @@
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="searchPlaceholder"
|
||||
class="input input-sm w-full bg-white/50 border-base-300/50 text-base-content placeholder:text-base-content/50"
|
||||
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredItems.length === 0" class="text-center py-8 text-white/60">
|
||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||
<p>{{ $t('catalog.empty.noResults') }}</p>
|
||||
</div>
|
||||
<!-- Content (scrollable) -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md text-white" />
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<!-- Products -->
|
||||
<template v-if="selectMode === 'product'">
|
||||
<div v-else-if="filteredItems.length === 0" class="text-center py-8 text-white/60">
|
||||
<Icon name="lucide:search-x" size="32" class="mb-2" />
|
||||
<p>{{ $t('catalog.empty.noResults') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<!-- Products -->
|
||||
<template v-if="selectMode === 'product'">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.uuid ?? index"
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<ProductCard
|
||||
:product="item"
|
||||
selectable
|
||||
compact
|
||||
:is-selected="drawerSelectedItem?.uuid === item.uuid"
|
||||
@select="onDrawerSelect(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Hubs -->
|
||||
<template v-else-if="selectMode === 'hub'">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.uuid ?? index"
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<HubCard
|
||||
:hub="item"
|
||||
selectable
|
||||
:is-selected="drawerSelectedItem?.uuid === item.uuid"
|
||||
@select="onDrawerSelect(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Suppliers -->
|
||||
<template v-else-if="selectMode === 'supplier'">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.uuid ?? index"
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<SupplierCard
|
||||
:supplier="item"
|
||||
selectable
|
||||
:is-selected="drawerSelectedItem?.uuid === item.uuid"
|
||||
@select="onDrawerSelect(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Infinite scroll sentinel -->
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.uuid ?? index"
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
v-if="hasMore && !searchQuery"
|
||||
ref="loadMoreSentinel"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<ProductCard
|
||||
:product="item"
|
||||
selectable
|
||||
compact
|
||||
:is-selected="selectedId === item.uuid"
|
||||
@select="onSelect(item)"
|
||||
/>
|
||||
<span v-if="loadingMore" class="loading loading-spinner loading-sm text-white/60" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Hubs -->
|
||||
<template v-else-if="selectMode === 'hub'">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.uuid ?? index"
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<HubCard
|
||||
:hub="item"
|
||||
selectable
|
||||
:is-selected="selectedId === item.uuid"
|
||||
@select="onSelect(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Suppliers -->
|
||||
<template v-else-if="selectMode === 'supplier'">
|
||||
<div
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="item.uuid ?? index"
|
||||
@mouseenter="emit('hover', item.uuid ?? null)"
|
||||
@mouseleave="emit('hover', null)"
|
||||
>
|
||||
<SupplierCard
|
||||
:supplier="item"
|
||||
selectable
|
||||
:is-selected="selectedId === item.uuid"
|
||||
@select="onSelect(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Infinite scroll sentinel -->
|
||||
<div
|
||||
v-if="hasMore && !searchQuery"
|
||||
ref="loadMoreSentinel"
|
||||
class="flex items-center justify-center py-4"
|
||||
>
|
||||
<span v-if="loadingMore" class="loading loading-spinner loading-sm text-white/60" />
|
||||
</div>
|
||||
</div>
|
||||
</MapPanel>
|
||||
|
||||
<!-- Footer with Apply button -->
|
||||
<div class="flex-shrink-0 p-4 border-t border-white/10">
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
:disabled="!drawerSelectedItem"
|
||||
@click="onApplyFilter"
|
||||
>
|
||||
{{ $t('catalog.applyFilter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -104,21 +118,26 @@ const props = defineProps<{
|
||||
products?: Item[]
|
||||
hubs?: Item[]
|
||||
suppliers?: Item[]
|
||||
selectedId?: string
|
||||
loading?: boolean
|
||||
loadingMore?: boolean
|
||||
hasMore?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'select': [type: string, item: Item]
|
||||
'close': []
|
||||
'load-more': []
|
||||
'hover': [uuid: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Get drawer functions from useCatalogSearch
|
||||
const {
|
||||
drawerSelectedItem,
|
||||
selectDrawerItem,
|
||||
applyDrawerFilter,
|
||||
closeDrawer
|
||||
} = useCatalogSearch()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const loadMoreSentinel = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -187,9 +206,15 @@ const filteredItems = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const onSelect = (item: Item) => {
|
||||
if (props.selectMode && item.uuid) {
|
||||
emit('select', props.selectMode, item)
|
||||
// Select item in drawer (doesn't apply filter yet)
|
||||
const onDrawerSelect = (item: Item) => {
|
||||
if (item.uuid && item.name) {
|
||||
selectDrawerItem(item.uuid, item.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filter and close drawer
|
||||
const onApplyFilter = () => {
|
||||
applyDrawerFilter()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,10 +39,18 @@
|
||||
<span class="text-white text-sm">{{ $t('common.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter by bounds checkbox (LEFT, next to panel) -->
|
||||
<!-- List button (LEFT, opens drawer) -->
|
||||
<button
|
||||
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 text-white text-sm hover:bg-black/40 transition-colors"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<Icon name="lucide:menu" size="16" />
|
||||
<span>{{ $t('catalog.list') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Filter by bounds checkbox (LEFT, next to list button) -->
|
||||
<label
|
||||
v-if="showPanel"
|
||||
class="absolute top-[116px] left-[26rem] z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 cursor-pointer text-white text-sm hover:bg-black/40 transition-colors"
|
||||
class="absolute top-[116px] left-32 z-20 hidden lg:flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-1.5 border border-white/10 cursor-pointer text-white text-sm hover:bg-black/40 transition-colors"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -90,20 +98,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Left overlay panel (shown when showPanel is true, below header) -->
|
||||
<div
|
||||
v-if="showPanel"
|
||||
class="absolute top-[116px] left-4 bottom-4 z-10 w-96 max-w-[calc(100vw-2rem)] hidden lg:block"
|
||||
>
|
||||
<div class="bg-black/30 backdrop-blur-md rounded-xl shadow-lg border border-white/10 p-4 h-full overflow-y-auto text-white">
|
||||
<slot name="panel" />
|
||||
<!-- Left drawer (slides from left when isDrawerOpen is true) -->
|
||||
<Transition name="slide-left">
|
||||
<div
|
||||
v-if="isDrawerOpen"
|
||||
class="absolute top-[116px] left-4 bottom-4 z-30 w-96 max-w-[calc(100vw-2rem)] hidden lg:block"
|
||||
>
|
||||
<div class="bg-black/50 backdrop-blur-md rounded-xl shadow-lg border border-white/10 h-full flex flex-col text-white">
|
||||
<slot name="panel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
<div class="lg:hidden absolute bottom-0 left-0 right-0 z-20">
|
||||
<!-- Mobile view toggle -->
|
||||
<div class="flex justify-end px-4 mb-2">
|
||||
<!-- Mobile controls: List button + view toggle -->
|
||||
<div class="flex justify-between px-4 mb-2">
|
||||
<!-- List button (mobile) -->
|
||||
<button
|
||||
class="flex items-center gap-2 bg-black/30 backdrop-blur-md rounded-lg px-3 py-2 border border-white/10 text-white text-sm"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<Icon name="lucide:menu" size="16" />
|
||||
<span>{{ $t('catalog.list') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Mobile view toggle -->
|
||||
<div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
|
||||
<button
|
||||
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
|
||||
@@ -135,24 +155,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile panel (collapsible) - only when showPanel is true -->
|
||||
<div
|
||||
v-if="showPanel"
|
||||
class="bg-black/30 backdrop-blur-md rounded-t-xl shadow-lg border border-white/10 transition-all duration-300 text-white"
|
||||
:class="mobilePanelExpanded ? 'h-[60vh]' : 'h-auto'"
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<!-- Mobile panel (collapsible) - only when drawer is open -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
class="flex justify-center py-2 cursor-pointer"
|
||||
@click="mobilePanelExpanded = !mobilePanelExpanded"
|
||||
v-if="isDrawerOpen"
|
||||
class="bg-black/50 backdrop-blur-md rounded-t-xl shadow-lg border border-white/10 transition-all duration-300 text-white h-[60vh]"
|
||||
>
|
||||
<div class="w-10 h-1 bg-white/30 rounded-full" />
|
||||
</div>
|
||||
<!-- Drag handle / close -->
|
||||
<div
|
||||
class="flex justify-center py-2 cursor-pointer"
|
||||
@click="closeDrawer"
|
||||
>
|
||||
<div class="w-10 h-1 bg-white/30 rounded-full" />
|
||||
</div>
|
||||
|
||||
<div class="px-4 pb-4 overflow-y-auto" :class="mobilePanelExpanded ? 'h-[calc(60vh-2rem)]' : 'max-h-48'">
|
||||
<slot name="panel" />
|
||||
<div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
|
||||
<slot name="panel" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -160,7 +181,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { MapBounds } from '~/components/catalog/CatalogMap.vue'
|
||||
|
||||
const { mapViewMode, setMapViewMode } = useCatalogSearch()
|
||||
const { mapViewMode, setMapViewMode, isDrawerOpen, openDrawer, closeDrawer } = useCatalogSearch()
|
||||
|
||||
// Point color based on map view mode
|
||||
const VIEW_MODE_COLORS = {
|
||||
@@ -320,3 +341,29 @@ const flyTo = (lat: number, lng: number, zoom = 8) => {
|
||||
|
||||
defineExpose({ flyTo, currentBounds })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Drawer slide animation (desktop - left) */
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-left-enter-from,
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Drawer slide animation (mobile - up) */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -260,6 +260,47 @@ export function useCatalogSearch() {
|
||||
mapViewCookie.value = mode
|
||||
}
|
||||
|
||||
// Drawer state for list view
|
||||
const isDrawerOpen = ref(false)
|
||||
const drawerSelectedItem = ref<{ uuid: string; name: string } | null>(null)
|
||||
|
||||
const openDrawer = () => {
|
||||
isDrawerOpen.value = true
|
||||
drawerSelectedItem.value = null
|
||||
|
||||
// Set selectMode based on mapViewMode so SelectionPanel shows the right list
|
||||
const newSelectMode: SelectMode = mapViewMode.value === 'hubs' ? 'hub'
|
||||
: mapViewMode.value === 'suppliers' ? 'supplier'
|
||||
: 'product'
|
||||
startSelect(newSelectMode)
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
isDrawerOpen.value = false
|
||||
drawerSelectedItem.value = null
|
||||
cancelSelect() // Also exit selection mode
|
||||
}
|
||||
|
||||
const selectDrawerItem = (uuid: string, name: string) => {
|
||||
drawerSelectedItem.value = { uuid, name }
|
||||
}
|
||||
|
||||
const applyDrawerFilter = () => {
|
||||
if (!drawerSelectedItem.value) {
|
||||
closeDrawer()
|
||||
return
|
||||
}
|
||||
|
||||
// Determine filter type based on mapViewMode
|
||||
const type = mapViewMode.value === 'hubs' ? 'hub'
|
||||
: mapViewMode.value === 'suppliers' ? 'supplier'
|
||||
: 'product' // offers -> select product from offer
|
||||
|
||||
const { uuid, name } = drawerSelectedItem.value
|
||||
selectItem(type, uuid, name)
|
||||
closeDrawer()
|
||||
}
|
||||
|
||||
// Catalog mode: explore (map browsing) or quote (search for offers)
|
||||
const catalogMode = computed<CatalogMode>(() => {
|
||||
return route.query.mode === 'quote' ? 'quote' : 'explore'
|
||||
@@ -292,6 +333,10 @@ export function useCatalogSearch() {
|
||||
searchQuery,
|
||||
mapViewMode,
|
||||
|
||||
// Drawer state
|
||||
isDrawerOpen,
|
||||
drawerSelectedItem,
|
||||
|
||||
// Colors
|
||||
entityColors,
|
||||
|
||||
@@ -317,6 +362,12 @@ export function useCatalogSearch() {
|
||||
setMapViewMode,
|
||||
setCatalogMode,
|
||||
|
||||
// Drawer actions
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
selectDrawerItem,
|
||||
applyDrawerFilter,
|
||||
|
||||
// Labels cache
|
||||
filterLabels
|
||||
}
|
||||
|
||||
@@ -27,8 +27,6 @@
|
||||
:loading="selectionLoading"
|
||||
:loading-more="selectionLoadingMore"
|
||||
:has-more="selectionHasMore && !filterByBounds"
|
||||
@select="onSelectItem"
|
||||
@close="cancelSelect"
|
||||
@load-more="onLoadMore"
|
||||
@hover="onHoverItem"
|
||||
/>
|
||||
|
||||
@@ -75,6 +75,8 @@
|
||||
"title": "Исследуйте рынок",
|
||||
"subtitle": "Переключайтесь между офферами, хабами и поставщиками"
|
||||
},
|
||||
"offers": "предложение | предложения | предложений"
|
||||
"offers": "предложение | предложения | предложений",
|
||||
"list": "Список",
|
||||
"applyFilter": "Применить фильтр"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user