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"> <div class="flex flex-col gap-4">
<h3 class="font-semibold text-lg">{{ $t('catalog.quote.title') }}</h3> <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 --> <!-- Quantity input -->
<div class="form-control"> <div class="form-control">
<label class="label py-1"> <label class="label py-1">

View File

@@ -1,9 +1,10 @@
<template> <template>
<MapPanel> <div class="flex flex-col h-full">
<template #header> <!-- Header -->
<div class="flex items-center justify-between mb-2"> <div class="flex-shrink-0 p-4 border-b border-white/10">
<h3 class="font-semibold text-base text-base-content">{{ title }}</h3> <div class="flex items-center justify-between mb-3">
<button class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content" @click="emit('close')"> <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" /> <Icon name="lucide:x" size="16" />
</button> </button>
</div> </div>
@@ -11,83 +12,96 @@
v-model="searchQuery" v-model="searchQuery"
type="text" type="text"
:placeholder="searchPlaceholder" :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>
<div v-else-if="filteredItems.length === 0" class="text-center py-8 text-white/60"> <!-- Content (scrollable) -->
<Icon name="lucide:search-x" size="32" class="mb-2" /> <div class="flex-1 overflow-y-auto p-4">
<p>{{ $t('catalog.empty.noResults') }}</p> <div v-if="loading" class="flex items-center justify-center py-8">
</div> <span class="loading loading-spinner loading-md text-white" />
</div>
<div v-else class="flex flex-col gap-2"> <div v-else-if="filteredItems.length === 0" class="text-center py-8 text-white/60">
<!-- Products --> <Icon name="lucide:search-x" size="32" class="mb-2" />
<template v-if="selectMode === 'product'"> <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 <div
v-for="(item, index) in filteredItems" v-if="hasMore && !searchQuery"
:key="item.uuid ?? index" ref="loadMoreSentinel"
@mouseenter="emit('hover', item.uuid ?? null)" class="flex items-center justify-center py-4"
@mouseleave="emit('hover', null)"
> >
<ProductCard <span v-if="loadingMore" class="loading loading-spinner loading-sm text-white/60" />
:product="item"
selectable
compact
:is-selected="selectedId === item.uuid"
@select="onSelect(item)"
/>
</div> </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>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -104,21 +118,26 @@ const props = defineProps<{
products?: Item[] products?: Item[]
hubs?: Item[] hubs?: Item[]
suppliers?: Item[] suppliers?: Item[]
selectedId?: string
loading?: boolean loading?: boolean
loadingMore?: boolean loadingMore?: boolean
hasMore?: boolean hasMore?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'select': [type: string, item: Item]
'close': []
'load-more': [] 'load-more': []
'hover': [uuid: string | null] 'hover': [uuid: string | null]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
// Get drawer functions from useCatalogSearch
const {
drawerSelectedItem,
selectDrawerItem,
applyDrawerFilter,
closeDrawer
} = useCatalogSearch()
const searchQuery = ref('') const searchQuery = ref('')
const loadMoreSentinel = ref<HTMLElement | null>(null) const loadMoreSentinel = ref<HTMLElement | null>(null)
@@ -187,9 +206,15 @@ const filteredItems = computed(() => {
) )
}) })
const onSelect = (item: Item) => { // Select item in drawer (doesn't apply filter yet)
if (props.selectMode && item.uuid) { const onDrawerSelect = (item: Item) => {
emit('select', props.selectMode, item) if (item.uuid && item.name) {
selectDrawerItem(item.uuid, item.name)
} }
} }
// Apply filter and close drawer
const onApplyFilter = () => {
applyDrawerFilter()
}
</script> </script>

View File

@@ -39,10 +39,18 @@
<span class="text-white text-sm">{{ $t('common.loading') }}</span> <span class="text-white text-sm">{{ $t('common.loading') }}</span>
</div> </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 <label
v-if="showPanel" 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"
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"
> >
<input <input
type="checkbox" type="checkbox"
@@ -90,20 +98,32 @@
</div> </div>
</div> </div>
<!-- Left overlay panel (shown when showPanel is true, below header) --> <!-- Left drawer (slides from left when isDrawerOpen is true) -->
<div <Transition name="slide-left">
v-if="showPanel" <div
class="absolute top-[116px] left-4 bottom-4 z-10 w-96 max-w-[calc(100vw-2rem)] hidden lg:block" 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/30 backdrop-blur-md rounded-xl shadow-lg border border-white/10 p-4 h-full overflow-y-auto text-white"> >
<slot name="panel" /> <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>
</div> </Transition>
<!-- Mobile bottom sheet --> <!-- Mobile bottom sheet -->
<div class="lg:hidden absolute bottom-0 left-0 right-0 z-20"> <div class="lg:hidden absolute bottom-0 left-0 right-0 z-20">
<!-- Mobile view toggle --> <!-- Mobile controls: List button + view toggle -->
<div class="flex justify-end px-4 mb-2"> <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"> <div class="flex gap-1 bg-black/30 backdrop-blur-md rounded-lg p-1 border border-white/10">
<button <button
class="flex items-center justify-center w-8 h-8 rounded-md transition-colors" class="flex items-center justify-center w-8 h-8 rounded-md transition-colors"
@@ -135,24 +155,25 @@
</div> </div>
</div> </div>
<!-- Mobile panel (collapsible) - only when showPanel is true --> <!-- Mobile panel (collapsible) - only when drawer is open -->
<div <Transition name="slide-up">
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 -->
<div <div
class="flex justify-center py-2 cursor-pointer" v-if="isDrawerOpen"
@click="mobilePanelExpanded = !mobilePanelExpanded" 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" /> <!-- Drag handle / close -->
</div> <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'"> <div class="px-4 pb-4 overflow-y-auto h-[calc(60vh-2rem)]">
<slot name="panel" /> <slot name="panel" />
</div>
</div> </div>
</div> </Transition>
</div> </div>
</div> </div>
</template> </template>
@@ -160,7 +181,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { MapBounds } from '~/components/catalog/CatalogMap.vue' 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 // Point color based on map view mode
const VIEW_MODE_COLORS = { const VIEW_MODE_COLORS = {
@@ -320,3 +341,29 @@ const flyTo = (lat: number, lng: number, zoom = 8) => {
defineExpose({ flyTo, currentBounds }) defineExpose({ flyTo, currentBounds })
</script> </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 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) // Catalog mode: explore (map browsing) or quote (search for offers)
const catalogMode = computed<CatalogMode>(() => { const catalogMode = computed<CatalogMode>(() => {
return route.query.mode === 'quote' ? 'quote' : 'explore' return route.query.mode === 'quote' ? 'quote' : 'explore'
@@ -292,6 +333,10 @@ export function useCatalogSearch() {
searchQuery, searchQuery,
mapViewMode, mapViewMode,
// Drawer state
isDrawerOpen,
drawerSelectedItem,
// Colors // Colors
entityColors, entityColors,
@@ -317,6 +362,12 @@ export function useCatalogSearch() {
setMapViewMode, setMapViewMode,
setCatalogMode, setCatalogMode,
// Drawer actions
openDrawer,
closeDrawer,
selectDrawerItem,
applyDrawerFilter,
// Labels cache // Labels cache
filterLabels filterLabels
} }

View File

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

View File

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