Simplify catalog UI - remove chips, add drawer for list
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:
Ruslan Bakiev
2026-01-26 14:36:42 +07:00
parent 65b07271d9
commit 0efc4eddfd
6 changed files with 233 additions and 168 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,8 +27,6 @@
:loading="selectionLoading"
:loading-more="selectionLoadingMore"
:has-more="selectionHasMore && !filterByBounds"
@select="onSelectItem"
@close="cancelSelect"
@load-more="onLoadMore"
@hover="onHoverItem"
/>

View File

@@ -75,6 +75,8 @@
"title": "Исследуйте рынок",
"subtitle": "Переключайтесь между офферами, хабами и поставщиками"
},
"offers": "предложение | предложения | предложений"
"offers": "предложение | предложения | предложений",
"list": "Список",
"applyFilter": "Применить фильтр"
}
}