Redesign header - move search bar into main navigation
All checks were successful
Build Docker Image / build (push) Successful in 3m10s
All checks were successful
Build Docker Image / build (push) Successful in 3m10s
- Move search input with tokens into center of header - Remove tabs (Search, Catalog, Orders, Seller) - Icons (bot, globe, user) remain on right side - Chips for filter selection below input - Delete GlobalSearchBar.vue and UnifiedSearchBar.vue - Share searchQuery via useState across composable calls - Simplify main page to just show hero
This commit is contained in:
@@ -1,37 +1,80 @@
|
|||||||
<template>
|
<template>
|
||||||
<header class="bg-base-100 shadow-md">
|
<header class="bg-base-100 shadow-md">
|
||||||
<div class="relative flex items-center h-16 px-4 lg:px-6">
|
<!-- Top row: Logo + Icons -->
|
||||||
|
<div class="flex items-center h-14 px-4 lg:px-6">
|
||||||
<!-- Left: Logo -->
|
<!-- Left: Logo -->
|
||||||
<div class="flex items-center">
|
<div class="flex items-center flex-shrink-0">
|
||||||
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
||||||
<span class="font-bold text-xl">Optovia</span>
|
<span class="font-bold text-xl">Optovia</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Center: Main tabs (absolutely centered on page) -->
|
<!-- Center: Search input with tokens -->
|
||||||
<nav class="hidden md:flex items-center gap-1 absolute left-1/2 -translate-x-1/2">
|
<div class="flex-1 flex justify-center px-4 max-w-2xl mx-auto">
|
||||||
<NuxtLink
|
<div
|
||||||
v-for="tab in visibleTabs"
|
class="flex items-center gap-2 w-full px-3 py-1.5 border border-base-300 rounded-lg bg-base-100 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary cursor-text"
|
||||||
:key="tab.key"
|
@click="focusInput"
|
||||||
:to="localePath(tab.path)"
|
|
||||||
class="px-4 py-2 rounded-full font-medium text-sm transition-colors hover:bg-base-200"
|
|
||||||
:class="{ 'bg-base-200 text-primary': isActiveTab(tab.key) }"
|
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
<Icon name="lucide:search" size="16" class="text-base-content/50 flex-shrink-0" />
|
||||||
</NuxtLink>
|
|
||||||
</nav>
|
<!-- Tokens + input inline -->
|
||||||
|
<div class="flex items-center gap-1.5 flex-wrap flex-1 min-w-0">
|
||||||
|
<!-- Active filter tokens -->
|
||||||
|
<div
|
||||||
|
v-for="token in activeTokens"
|
||||||
|
:key="token.type"
|
||||||
|
class="badge badge-sm gap-1 cursor-pointer hover:badge-primary transition-colors flex-shrink-0"
|
||||||
|
@click.stop="$emit('edit-token', token.type)"
|
||||||
|
>
|
||||||
|
<Icon :name="token.icon" size="12" />
|
||||||
|
<span class="max-w-20 truncate">{{ token.label }}</span>
|
||||||
|
<button
|
||||||
|
class="hover:text-error"
|
||||||
|
@click.stop="$emit('remove-token', token.type)"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:x" size="10" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active selection mode indicator -->
|
||||||
|
<div
|
||||||
|
v-if="selectMode"
|
||||||
|
class="badge badge-sm badge-outline badge-primary gap-1 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Icon :name="selectModeIcon" size="12" />
|
||||||
|
{{ selectModeLabel }}:
|
||||||
|
<button
|
||||||
|
class="hover:text-error"
|
||||||
|
@click.stop="$emit('cancel-select')"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:x" size="10" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="localSearchQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="flex-1 min-w-24 bg-transparent outline-none text-sm"
|
||||||
|
@input="$emit('update:search-query', localSearchQuery)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right: AI + Globe + Team + User -->
|
<!-- Right: AI + Globe + Team + User -->
|
||||||
<div class="flex items-center gap-2 ml-auto">
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
<!-- AI Assistant button -->
|
<!-- AI Assistant button -->
|
||||||
<NuxtLink :to="localePath('/clientarea/ai')" class="btn btn-ghost btn-circle">
|
<NuxtLink :to="localePath('/clientarea/ai')" class="btn btn-ghost btn-circle btn-sm">
|
||||||
<Icon name="lucide:bot" size="20" />
|
<Icon name="lucide:bot" size="18" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|
||||||
<!-- Globe (language/currency) dropdown -->
|
<!-- Globe (language/currency) dropdown -->
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
<button tabindex="0" class="btn btn-ghost btn-circle">
|
<button tabindex="0" class="btn btn-ghost btn-circle btn-sm">
|
||||||
<Icon name="lucide:globe" size="20" />
|
<Icon name="lucide:globe" size="18" />
|
||||||
</button>
|
</button>
|
||||||
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
|
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
|
||||||
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
|
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
|
||||||
@@ -60,12 +103,12 @@
|
|||||||
<!-- Team dropdown -->
|
<!-- Team dropdown -->
|
||||||
<template v-if="loggedIn && userData?.teams?.length">
|
<template v-if="loggedIn && userData?.teams?.length">
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
<button tabindex="0" class="btn btn-ghost gap-2">
|
<button tabindex="0" class="btn btn-ghost btn-sm gap-1">
|
||||||
<Icon name="lucide:building-2" size="18" />
|
<Icon name="lucide:building-2" size="16" />
|
||||||
<span class="hidden sm:inline max-w-32 truncate">
|
<span class="hidden lg:inline max-w-24 truncate text-xs">
|
||||||
{{ userData?.activeTeam?.name || $t('common.selectTeam') }}
|
{{ userData?.activeTeam?.name || $t('common.selectTeam') }}
|
||||||
</span>
|
</span>
|
||||||
<Icon name="lucide:chevron-down" size="14" />
|
<Icon name="lucide:chevron-down" size="12" />
|
||||||
</button>
|
</button>
|
||||||
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2 shadow-lg border border-base-300">
|
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2 shadow-lg border border-base-300">
|
||||||
<li class="menu-title"><span>{{ $t('common.teams') }}</span></li>
|
<li class="menu-title"><span>{{ $t('common.teams') }}</span></li>
|
||||||
@@ -92,12 +135,12 @@
|
|||||||
<template v-if="sessionChecked">
|
<template v-if="sessionChecked">
|
||||||
<template v-if="loggedIn">
|
<template v-if="loggedIn">
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle btn-sm avatar">
|
||||||
<div class="w-10 rounded-full">
|
<div class="w-8 rounded-full">
|
||||||
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-sm"
|
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-xs"
|
||||||
>
|
>
|
||||||
{{ userInitials }}
|
{{ userInitials }}
|
||||||
</div>
|
</div>
|
||||||
@@ -135,22 +178,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile tabs (shown below header on small screens) -->
|
<!-- Bottom row: Quick filter chips -->
|
||||||
<nav class="md:hidden flex items-center justify-center gap-1 py-2 border-t border-base-300 overflow-x-auto">
|
<div
|
||||||
<NuxtLink
|
v-if="availableChips.length > 0"
|
||||||
v-for="tab in visibleTabs"
|
class="flex items-center justify-center gap-2 px-4 py-1.5 border-t border-base-200"
|
||||||
:key="tab.key"
|
>
|
||||||
:to="localePath(tab.path)"
|
<button
|
||||||
class="px-4 py-2 rounded-full text-sm font-medium hover:bg-base-200"
|
v-for="chip in availableChips"
|
||||||
:class="{ 'bg-base-200 text-primary': isActiveTab(tab.key) }"
|
:key="chip.type"
|
||||||
|
class="btn btn-xs btn-ghost gap-1"
|
||||||
|
@click="$emit('start-select', chip.type)"
|
||||||
>
|
>
|
||||||
{{ tab.label }}
|
<Icon name="lucide:plus" size="12" />
|
||||||
</NuxtLink>
|
{{ chip.label }}
|
||||||
</nav>
|
</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { SelectMode } from '~/composables/useCatalogSearch'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
sessionChecked?: boolean
|
sessionChecked?: boolean
|
||||||
loggedIn?: boolean
|
loggedIn?: boolean
|
||||||
@@ -165,38 +213,62 @@ const props = defineProps<{
|
|||||||
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
||||||
} | null
|
} | null
|
||||||
isSeller?: boolean
|
isSeller?: boolean
|
||||||
|
// Search props
|
||||||
|
activeTokens?: Array<{ type: string; id: string; label: string; icon: string }>
|
||||||
|
availableChips?: Array<{ type: string; label: string }>
|
||||||
|
selectMode?: SelectMode
|
||||||
|
searchQuery?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits(['toggle-theme', 'sign-out', 'sign-in', 'switch-team'])
|
defineEmits([
|
||||||
|
'toggle-theme',
|
||||||
|
'sign-out',
|
||||||
|
'sign-in',
|
||||||
|
'switch-team',
|
||||||
|
// Search events
|
||||||
|
'start-select',
|
||||||
|
'cancel-select',
|
||||||
|
'edit-token',
|
||||||
|
'remove-token',
|
||||||
|
'update:search-query'
|
||||||
|
])
|
||||||
|
|
||||||
const localePath = useLocalePath()
|
const localePath = useLocalePath()
|
||||||
const { locale, locales } = useI18n()
|
const { locale, locales } = useI18n()
|
||||||
const switchLocalePath = useSwitchLocalePath()
|
const switchLocalePath = useSwitchLocalePath()
|
||||||
const route = useRoute()
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const inputRef = ref<HTMLInputElement>()
|
||||||
{ key: 'search', label: t('cabinetNav.search'), path: '/', auth: false },
|
const localSearchQuery = ref(props.searchQuery || '')
|
||||||
{ key: 'catalog', label: t('cabinetNav.catalog'), path: '/catalog', auth: false },
|
|
||||||
{ key: 'orders', label: t('cabinetNav.orders'), path: '/clientarea/orders', auth: true },
|
|
||||||
{ key: 'seller', label: t('cabinetNav.seller'), path: '/clientarea/offers', auth: true, seller: true },
|
|
||||||
])
|
|
||||||
|
|
||||||
const visibleTabs = computed(() => {
|
watch(() => props.searchQuery, (val) => {
|
||||||
return tabs.value.filter(tab => {
|
localSearchQuery.value = val || ''
|
||||||
if (tab.auth && !props.loggedIn) return false
|
|
||||||
if (tab.seller && !props.isSeller) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const isActiveTab = (key: string) => {
|
const focusInput = () => {
|
||||||
const path = route.path
|
inputRef.value?.focus()
|
||||||
if (key === 'search') return path === '/' || path === '/en' || path === '/ru'
|
|
||||||
if (key === 'catalog') return path.startsWith('/catalog') || path.includes('/en/catalog') || path.includes('/ru/catalog')
|
|
||||||
if (key === 'orders') return path.includes('/clientarea/orders') || path.includes('/clientarea/addresses') || path.includes('/clientarea/billing')
|
|
||||||
if (key === 'seller') return path.includes('/clientarea/offers')
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
if (props.selectMode === 'product') return t('catalog.search.searchProducts')
|
||||||
|
if (props.selectMode === 'supplier') return t('catalog.search.searchSuppliers')
|
||||||
|
if (props.selectMode === 'hub') return t('catalog.search.searchHubs')
|
||||||
|
if (!props.activeTokens?.length) return t('catalog.search.placeholder')
|
||||||
|
return t('catalog.search.refine')
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectModeLabel = computed(() => {
|
||||||
|
if (props.selectMode === 'product') return t('catalog.filters.product')
|
||||||
|
if (props.selectMode === 'supplier') return t('catalog.filters.supplier')
|
||||||
|
if (props.selectMode === 'hub') return t('catalog.filters.hub')
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectModeIcon = computed(() => {
|
||||||
|
if (props.selectMode === 'product') return 'lucide:package'
|
||||||
|
if (props.selectMode === 'supplier') return 'lucide:factory'
|
||||||
|
if (props.selectMode === 'hub') return 'lucide:map-pin'
|
||||||
|
return 'lucide:search'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="bg-base-100 py-4 px-4 lg:px-6">
|
|
||||||
<div class="flex items-center justify-center">
|
|
||||||
<form
|
|
||||||
@submit.prevent="handleSearch"
|
|
||||||
class="flex items-center bg-base-100 rounded-full border border-base-300 shadow-sm hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<!-- Product field (clickable, navigates to /goods) -->
|
|
||||||
<div
|
|
||||||
class="flex flex-col px-4 py-2 min-w-48 pl-6 rounded-l-full hover:bg-base-200/50 border-r border-base-300 cursor-pointer"
|
|
||||||
@click="goToProductSelection"
|
|
||||||
>
|
|
||||||
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
|
|
||||||
{{ $t('search.product') }}
|
|
||||||
</label>
|
|
||||||
<div class="text-sm" :class="productDisplay ? 'text-base-content' : 'text-base-content/50'">
|
|
||||||
{{ productDisplay || $t('search.product_placeholder') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quantity field (editable) -->
|
|
||||||
<div class="flex flex-col px-4 py-2 min-w-48 hover:bg-base-200/50 border-r border-base-300">
|
|
||||||
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
|
|
||||||
{{ $t('search.quantity') }}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
v-model="quantity"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:placeholder="$t('search.quantity_placeholder')"
|
|
||||||
class="w-full bg-transparent outline-none text-sm"
|
|
||||||
@change="syncQuantityToStore"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Destination field (clickable, navigates to /select-location) -->
|
|
||||||
<div
|
|
||||||
class="flex flex-col px-4 py-2 min-w-48 hover:bg-base-200/50 cursor-pointer"
|
|
||||||
@click="goToLocationSelection"
|
|
||||||
>
|
|
||||||
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
|
|
||||||
{{ $t('search.destination') }}
|
|
||||||
</label>
|
|
||||||
<div class="text-sm" :class="locationDisplay ? 'text-base-content' : 'text-base-content/50'">
|
|
||||||
{{ locationDisplay || $t('search.destination_placeholder') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search button -->
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary btn-circle ml-2 mr-1"
|
|
||||||
:disabled="!canSearch"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:search" size="18" />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
const emit = defineEmits<{
|
|
||||||
search: [params: { productUuid?: string; quantity?: number; locationUuid?: string }]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const localePath = useLocalePath()
|
|
||||||
const searchStore = useSearchStore()
|
|
||||||
|
|
||||||
// Read from searchStore
|
|
||||||
const productDisplay = computed(() => searchStore.searchForm.product || '')
|
|
||||||
const productUuid = computed(() => searchStore.searchForm.productUuid || '')
|
|
||||||
const locationDisplay = computed(() => searchStore.searchForm.location || '')
|
|
||||||
const locationUuid = computed(() => searchStore.searchForm.locationUuid || '')
|
|
||||||
|
|
||||||
// Quantity - local state synced with store
|
|
||||||
const quantity = ref<number | undefined>(
|
|
||||||
searchStore.searchForm.quantity ? Number(searchStore.searchForm.quantity) : undefined
|
|
||||||
)
|
|
||||||
|
|
||||||
const syncQuantityToStore = () => {
|
|
||||||
if (quantity.value) {
|
|
||||||
searchStore.setQuantity(String(quantity.value))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigation to selection pages
|
|
||||||
const goToProductSelection = () => {
|
|
||||||
navigateTo(localePath('/goods'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToLocationSelection = () => {
|
|
||||||
navigateTo(localePath('/select-location') + '?mode=search')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can search - need at least product selected
|
|
||||||
const canSearch = computed(() => {
|
|
||||||
return !!productUuid.value
|
|
||||||
})
|
|
||||||
|
|
||||||
// Search handler - navigate to catalog offers flow
|
|
||||||
const handleSearch = () => {
|
|
||||||
if (!canSearch.value) return
|
|
||||||
|
|
||||||
// Sync quantity to store
|
|
||||||
syncQuantityToStore()
|
|
||||||
|
|
||||||
// Build query params
|
|
||||||
const query: Record<string, string> = {}
|
|
||||||
if (quantity.value) {
|
|
||||||
query.quantity = String(quantity.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigate to unified catalog
|
|
||||||
if (productUuid.value && locationUuid.value) {
|
|
||||||
// Both product and hub selected -> show offers
|
|
||||||
router.push({
|
|
||||||
path: localePath('/catalog'),
|
|
||||||
query: { product: productUuid.value, hub: locationUuid.value, ...query }
|
|
||||||
})
|
|
||||||
} else if (productUuid.value) {
|
|
||||||
// Only product selected -> show hubs for product
|
|
||||||
router.push({
|
|
||||||
path: localePath('/catalog'),
|
|
||||||
query: { product: productUuid.value, ...query }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
emit('search', {
|
|
||||||
productUuid: productUuid.value,
|
|
||||||
quantity: quantity.value,
|
|
||||||
locationUuid: locationUuid.value
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watch store changes to sync quantity
|
|
||||||
watch(() => searchStore.searchForm.quantity, (val) => {
|
|
||||||
if (val) {
|
|
||||||
quantity.value = Number(val)
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
</script>
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="bg-base-100 rounded-box shadow-md">
|
|
||||||
<!-- Search bar with tokens inside -->
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 px-3 py-2 border border-base-300 rounded-lg bg-base-100 focus-within:border-primary focus-within:ring-1 focus-within:ring-primary cursor-text"
|
|
||||||
@click="focusInput"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:search" size="18" class="text-base-content/50 flex-shrink-0" />
|
|
||||||
|
|
||||||
<!-- Tokens + input inline -->
|
|
||||||
<div class="flex items-center gap-1.5 flex-wrap flex-1 min-w-0">
|
|
||||||
<!-- Active filter tokens -->
|
|
||||||
<div
|
|
||||||
v-for="token in activeTokens"
|
|
||||||
:key="token.type"
|
|
||||||
class="badge badge-sm gap-1 cursor-pointer hover:badge-primary transition-colors flex-shrink-0"
|
|
||||||
@click.stop="onEditToken(token.type)"
|
|
||||||
>
|
|
||||||
<Icon :name="token.icon" size="12" />
|
|
||||||
<span class="max-w-24 truncate">{{ token.label }}</span>
|
|
||||||
<button
|
|
||||||
class="hover:text-error"
|
|
||||||
@click.stop="onRemoveToken(token.type)"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:x" size="10" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Active selection mode indicator -->
|
|
||||||
<div
|
|
||||||
v-if="selectMode"
|
|
||||||
class="badge badge-sm badge-outline badge-primary gap-1 flex-shrink-0"
|
|
||||||
>
|
|
||||||
<Icon :name="selectModeIcon" size="12" />
|
|
||||||
{{ selectModeLabel }}:
|
|
||||||
<button
|
|
||||||
class="hover:text-error"
|
|
||||||
@click.stop="onCancelSelect"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:x" size="10" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search input (no border, grows to fill) -->
|
|
||||||
<input
|
|
||||||
ref="inputRef"
|
|
||||||
v-model="localSearchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
class="flex-1 min-w-32 bg-transparent outline-none text-sm"
|
|
||||||
@input="onSearchInput"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick filter chips -->
|
|
||||||
<div
|
|
||||||
v-if="availableChips.length > 0"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 border-t border-base-200"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="chip in availableChips"
|
|
||||||
:key="chip.type"
|
|
||||||
class="btn btn-xs btn-ghost gap-1"
|
|
||||||
@click="onStartSelect(chip.type)"
|
|
||||||
>
|
|
||||||
<Icon name="lucide:plus" size="12" />
|
|
||||||
{{ chip.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { SelectMode } from '~/composables/useCatalogSearch'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
activeTokens: Array<{ type: string; id: string; label: string; icon: string }>
|
|
||||||
availableChips: Array<{ type: string; label: string }>
|
|
||||||
selectMode: SelectMode
|
|
||||||
searchQuery: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'start-select', type: string): void
|
|
||||||
(e: 'cancel-select'): void
|
|
||||||
(e: 'edit-token', type: string): void
|
|
||||||
(e: 'remove-token', type: string): void
|
|
||||||
(e: 'update:search-query', value: string): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const inputRef = ref<HTMLInputElement>()
|
|
||||||
const localSearchQuery = ref(props.searchQuery)
|
|
||||||
|
|
||||||
watch(() => props.searchQuery, (val) => {
|
|
||||||
localSearchQuery.value = val
|
|
||||||
})
|
|
||||||
|
|
||||||
const focusInput = () => {
|
|
||||||
inputRef.value?.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
const placeholder = computed(() => {
|
|
||||||
if (props.selectMode === 'product') return t('catalog.search.searchProducts')
|
|
||||||
if (props.selectMode === 'supplier') return t('catalog.search.searchSuppliers')
|
|
||||||
if (props.selectMode === 'hub') return t('catalog.search.searchHubs')
|
|
||||||
if (props.activeTokens.length === 0) return t('catalog.search.placeholder')
|
|
||||||
return t('catalog.search.refine')
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectModeLabel = computed(() => {
|
|
||||||
if (props.selectMode === 'product') return t('catalog.filters.product')
|
|
||||||
if (props.selectMode === 'supplier') return t('catalog.filters.supplier')
|
|
||||||
if (props.selectMode === 'hub') return t('catalog.filters.hub')
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectModeIcon = computed(() => {
|
|
||||||
if (props.selectMode === 'product') return 'lucide:package'
|
|
||||||
if (props.selectMode === 'supplier') return 'lucide:factory'
|
|
||||||
if (props.selectMode === 'hub') return 'lucide:map-pin'
|
|
||||||
return 'lucide:search'
|
|
||||||
})
|
|
||||||
|
|
||||||
const onStartSelect = (type: string) => {
|
|
||||||
emit('start-select', type)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCancelSelect = () => {
|
|
||||||
emit('cancel-select')
|
|
||||||
}
|
|
||||||
|
|
||||||
const onEditToken = (type: string) => {
|
|
||||||
emit('edit-token', type)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onRemoveToken = (type: string) => {
|
|
||||||
emit('remove-token', type)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSearchInput = () => {
|
|
||||||
emit('update:search-query', localSearchQuery.value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -216,8 +216,8 @@ export function useCatalogSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text search (for filtering within current grid)
|
// Text search (for filtering within current grid) - shared via useState
|
||||||
const searchQuery = ref('')
|
const searchQuery = useState<string>('catalog-search-query', () => '')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="min-h-screen flex flex-col bg-base-300">
|
<div class="min-h-screen flex flex-col bg-base-300">
|
||||||
<!-- Fixed Header Container (MainNav + SubNav) - slides up on scroll -->
|
<!-- Fixed Header Container (MainNav + SubNav) - slides up on scroll -->
|
||||||
<div class="fixed top-0 left-0 right-0 z-40" :style="headerStyle">
|
<div class="fixed top-0 left-0 right-0 z-40" :style="headerStyle">
|
||||||
<!-- Main Navigation (Logo + Tabs + User) -->
|
<!-- Main Navigation (Logo + Search + User) -->
|
||||||
<MainNavigation
|
<MainNavigation
|
||||||
:session-checked="sessionChecked"
|
:session-checked="sessionChecked"
|
||||||
:logged-in="isLoggedIn"
|
:logged-in="isLoggedIn"
|
||||||
@@ -12,10 +12,19 @@
|
|||||||
:theme="theme"
|
:theme="theme"
|
||||||
:user-data="userData"
|
:user-data="userData"
|
||||||
:is-seller="isSeller"
|
:is-seller="isSeller"
|
||||||
|
:active-tokens="activeTokens"
|
||||||
|
:available-chips="availableChips"
|
||||||
|
:select-mode="selectMode"
|
||||||
|
:search-query="searchQuery"
|
||||||
@toggle-theme="toggleTheme"
|
@toggle-theme="toggleTheme"
|
||||||
@sign-out="onClickSignOut"
|
@sign-out="onClickSignOut"
|
||||||
@sign-in="signIn()"
|
@sign-in="signIn()"
|
||||||
@switch-team="switchToTeam"
|
@switch-team="switchToTeam"
|
||||||
|
@start-select="startSelect"
|
||||||
|
@cancel-select="cancelSelect"
|
||||||
|
@edit-token="editFilter"
|
||||||
|
@remove-token="removeFilter"
|
||||||
|
@update:search-query="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Sub Navigation (section-specific tabs) - only for non-catalog sections -->
|
<!-- Sub Navigation (section-specific tabs) - only for non-catalog sections -->
|
||||||
@@ -25,9 +34,6 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Search Bar - only on home page, in document flow -->
|
|
||||||
<GlobalSearchBar v-if="showSearch" class="border-b border-base-300 mt-16" />
|
|
||||||
|
|
||||||
<!-- Page content - padding-top compensates for fixed header -->
|
<!-- Page content - padding-top compensates for fixed header -->
|
||||||
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
|
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -41,6 +47,18 @@ const siteUrl = runtimeConfig.public.siteUrl || 'https://optovia.ru/'
|
|||||||
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
|
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Catalog search state
|
||||||
|
const {
|
||||||
|
selectMode,
|
||||||
|
searchQuery,
|
||||||
|
activeTokens,
|
||||||
|
availableChips,
|
||||||
|
startSelect,
|
||||||
|
cancelSelect,
|
||||||
|
removeFilter,
|
||||||
|
editFilter
|
||||||
|
} = useCatalogSearch()
|
||||||
|
|
||||||
// Collapsible header for catalog pages
|
// Collapsible header for catalog pages
|
||||||
const { headerOffset, isCollapsed } = useScrollCollapse(118)
|
const { headerOffset, isCollapsed } = useScrollCollapse(118)
|
||||||
|
|
||||||
@@ -101,9 +119,6 @@ const isCatalogSection = computed(() => {
|
|||||||
route.path.startsWith('/ru/catalog')
|
route.path.startsWith('/ru/catalog')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Show search bar only on main page
|
|
||||||
const showSearch = computed(() => isHomePage.value)
|
|
||||||
|
|
||||||
// Collapsible header logic - only for pages with SubNav
|
// Collapsible header logic - only for pages with SubNav
|
||||||
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value)
|
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value)
|
||||||
const canCollapse = computed(() => hasSubNav.value)
|
const canCollapse = computed(() => hasSubNav.value)
|
||||||
@@ -116,10 +131,10 @@ const headerStyle = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Main content padding-top to compensate for fixed header
|
// Main content padding-top to compensate for fixed header
|
||||||
// 64px = MainNav only (home, catalog)
|
// 90px = MainNav with search (2 rows: top 56px + chips 34px)
|
||||||
// 118px = MainNav + SubNav (orders, seller, settings)
|
// 144px = MainNav + SubNav (orders, seller, settings)
|
||||||
const mainStyle = computed(() => ({
|
const mainStyle = computed(() => ({
|
||||||
paddingTop: (isHomePage.value || isCatalogSection.value) ? '64px' : '118px'
|
paddingTop: (isHomePage.value || isCatalogSection.value) ? '90px' : '144px'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Provide collapsed state to child components (CatalogPage needs it for map positioning)
|
// Provide collapsed state to child components (CatalogPage needs it for map positioning)
|
||||||
|
|||||||
@@ -13,20 +13,6 @@
|
|||||||
@select="onMapSelect"
|
@select="onMapSelect"
|
||||||
@update:hovered-id="hoveredId = $event"
|
@update:hovered-id="hoveredId = $event"
|
||||||
>
|
>
|
||||||
<template #searchBar>
|
|
||||||
<UnifiedSearchBar
|
|
||||||
:active-tokens="activeTokens"
|
|
||||||
:available-chips="availableChips"
|
|
||||||
:select-mode="selectMode"
|
|
||||||
:search-query="searchQuery"
|
|
||||||
@start-select="startSelect"
|
|
||||||
@cancel-select="cancelSelect"
|
|
||||||
@edit-token="editFilter"
|
|
||||||
@remove-token="removeFilter"
|
|
||||||
@update:search-query="searchQuery = $event"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #header>
|
<template #header>
|
||||||
<Text v-if="!isLoading" tone="muted">{{ headerText }}</Text>
|
<Text v-if="!isLoading" tone="muted">{{ headerText }}</Text>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,29 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<CatalogPage
|
<div class="flex-1 flex items-center justify-center py-8">
|
||||||
:items="[]"
|
<CatalogHero @start-select="startSelect" />
|
||||||
:loading="false"
|
</div>
|
||||||
:total-count="0"
|
|
||||||
:with-map="false"
|
|
||||||
map-id="main-catalog-map"
|
|
||||||
>
|
|
||||||
<template #searchBar>
|
|
||||||
<UnifiedSearchBar
|
|
||||||
:active-tokens="activeTokens"
|
|
||||||
:available-chips="availableChips"
|
|
||||||
:select-mode="selectMode"
|
|
||||||
:search-query="searchQuery"
|
|
||||||
@start-select="startSelect"
|
|
||||||
@cancel-select="cancelSelect"
|
|
||||||
@edit-token="editFilter"
|
|
||||||
@remove-token="removeFilter"
|
|
||||||
@update:search-query="searchQuery = $event"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #empty>
|
|
||||||
<CatalogHero @start-select="startSelect" />
|
|
||||||
</template>
|
|
||||||
</CatalogPage>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -31,14 +9,5 @@ definePageMeta({
|
|||||||
layout: 'topnav'
|
layout: 'topnav'
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const { startSelect } = useCatalogSearch()
|
||||||
selectMode,
|
|
||||||
searchQuery,
|
|
||||||
activeTokens,
|
|
||||||
availableChips,
|
|
||||||
startSelect,
|
|
||||||
cancelSelect,
|
|
||||||
removeFilter,
|
|
||||||
editFilter
|
|
||||||
} = useCatalogSearch()
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user