Files
webapp/app/components/navigation/MainNavigation.vue
Ruslan Bakiev 85913a760d
All checks were successful
Build Docker Image / build (push) Successful in 4m53s
Fix main navigation markup
2026-02-07 17:39:07 +07:00

553 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<header
class="relative overflow-hidden"
:class="headerClasses"
:style="{ height: `${height}px` }"
>
<div class="absolute top-0 left-0 right-0 pointer-events-none glass-topfade" :style="glassStyle" />
<!-- Single row: Logo + Search + Icons -->
<div
class="relative z-10 flex px-4 lg:px-6 gap-4"
:class="isHeroLayout ? 'items-start pt-4' : 'items-center'"
:style="rowStyle"
>
<!-- Left: Logo + AI button + Nav links (top aligned) -->
<div class="flex items-center flex-shrink-0 rounded-full glass-bright">
<div class="flex items-center gap-2 px-4 py-2">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-bold text-xl" :class="useWhiteText ? 'text-white' : 'text-base-content'">Optovia</span>
</NuxtLink>
<button
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="[
useWhiteText
? (chatOpen ? 'bg-white/20 text-white' : 'text-white/70 hover:text-white hover:bg-white/10')
: (chatOpen ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')
]"
aria-label="Toggle AI assistant"
@click="$emit('toggle-chat')"
>
<Icon name="lucide:bot" size="18" />
</button>
</div>
<!-- Service nav links -->
<div v-if="showModeToggle" class="w-px h-6 bg-white/20 self-center" />
<div v-if="showModeToggle" class="flex items-center px-3 py-2">
<nav class="flex items-center gap-1">
<button
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
:class="showActiveMode && catalogMode === 'explore' && !isClientArea
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
@click="$emit('set-catalog-mode', 'explore')"
>
{{ $t('catalog.modes.explore') }}
</button>
<button
class="px-3 py-1 text-sm font-medium rounded-full transition-colors"
:class="showActiveMode && catalogMode === 'quote' && !isClientArea
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
@click="$emit('set-catalog-mode', 'quote')"
>
{{ $t('catalog.modes.quote') }}
</button>
<!-- Role switcher: Я клиент + dropdown -->
<div v-if="loggedIn" class="flex items-center">
<NuxtLink
:to="localePath(currentRole === 'SELLER' ? '/clientarea/offers' : '/clientarea/orders')"
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
:class="isClientArea
? (useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200')"
>
{{ currentRole === 'SELLER' ? $t('cabinetNav.roles.seller') : $t('cabinetNav.roles.client') }}
</NuxtLink>
<!-- Dropdown для переключения роли (если есть обе роли) -->
<div v-if="hasMultipleRoles" class="dropdown dropdown-end">
<button
tabindex="0"
class="p-1 ml-0.5 transition-colors"
:class="useWhiteText ? 'text-white/50 hover:text-white' : 'text-base-content/50 hover:text-base-content'"
>
<Icon name="lucide:chevron-down" size="14" />
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-48 p-2 shadow-lg border border-base-300">
<li>
<a
:class="{ active: currentRole === 'BUYER' }"
@click="$emit('switch-role', 'BUYER')"
>
{{ $t('cabinetNav.roles.client') }}
</a>
</li>
<li>
<a
:class="{ active: currentRole === 'SELLER' }"
@click="$emit('switch-role', 'SELLER')"
>
{{ $t('cabinetNav.roles.seller') }}
</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
<!-- Center: Search input OR Client Area tabs (vertically centered) -->
<div
class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 transition-all"
:class="isHeroLayout ? 'justify-start' : 'justify-center'"
:style="centerStyle"
>
<!-- Hero slot for home page title -->
<slot name="hero" />
<!-- Client Area tabs -->
<template v-if="isClientArea">
<div class="flex items-center gap-1 rounded-full glass-bright p-1">
<!-- BUYER tabs -->
<template v-if="currentRole !== 'SELLER'">
<NuxtLink
:to="localePath('/clientarea/orders')"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
:class="isClientAreaTabActive('/clientarea/orders') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
>
{{ $t('cabinetNav.orders') }}
</NuxtLink>
<NuxtLink
:to="localePath('/clientarea/addresses')"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
:class="isClientAreaTabActive('/clientarea/addresses') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
>
{{ $t('cabinetNav.addresses') }}
</NuxtLink>
</template>
<!-- SELLER tabs -->
<template v-else>
<NuxtLink
:to="localePath('/clientarea/offers')"
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap"
:class="isClientAreaTabActive('/clientarea/offers') ? 'bg-primary text-primary-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200/50'"
>
{{ $t('cabinetNav.myOffers') }}
</NuxtLink>
</template>
</div>
</template>
<!-- Quote mode: Simple segmented input with search inside (white glass) -->
<template v-else-if="catalogMode === 'quote'">
<div class="flex items-center w-full rounded-full glass-bright overflow-hidden">
<!-- Product segment -->
<button
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 rounded-l-full transition-colors min-w-0"
@click="$emit('edit-token', 'product')"
>
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.product') }}</div>
<div class="font-medium truncate text-base-content">{{ productLabel || $t('catalog.quote.selectProduct') }}</div>
</button>
<div class="w-px h-8 bg-white/20 self-center" />
<!-- Hub segment -->
<button
class="flex-1 px-4 py-2 text-left hover:bg-base-200/50 transition-colors min-w-0"
@click="$emit('edit-token', 'hub')"
>
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.hub') }}</div>
<div class="font-medium truncate text-base-content">{{ hubLabel || $t('catalog.quote.selectHub') }}</div>
</button>
<div class="w-px h-8 bg-white/20 self-center" />
<!-- Quantity segment (inline input) -->
<div class="flex-1 px-4 py-2 min-w-0">
<div class="text-xs text-base-content/60">{{ $t('catalog.filters.quantity') }}</div>
<div class="flex items-center gap-1">
<input
v-model="localQuantity"
type="number"
min="0"
step="0.1"
placeholder="—"
class="w-16 font-medium bg-transparent outline-none text-base-content [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@blur="$emit('update-quantity', localQuantity)"
@keyup.enter="$emit('update-quantity', localQuantity)"
/>
<span v-if="localQuantity" class="text-base-content/60 text-sm">{{ $t('units.t') }}</span>
</div>
</div>
<!-- Search button inside -->
<button
class="btn btn-primary btn-circle m-1"
:disabled="!canSearch"
@click="$emit('search')"
>
<Icon name="lucide:search" size="18" />
</button>
</div>
</template>
<!-- Explore mode: Regular pill input + chips (white glass) -->
<template v-else>
<!-- Big pill input -->
<div
class="flex items-center gap-3 w-full px-5 py-3 rounded-full glass-bright focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/20 transition-all cursor-text"
@click="focusInput"
>
<Icon name="lucide:search" size="22" class="text-primary flex-shrink-0" />
<!-- Tokens + input inline (no wrap to prevent height change) -->
<div class="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<!-- Active filter tokens (outline style with icon in circle) -->
<div
v-for="token in activeTokens"
:key="token.type"
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border-2 cursor-pointer hover:opacity-80 transition-all flex-shrink-0"
:style="{ borderColor: getTokenColor(token.type), color: getTokenColor(token.type) }"
@click.stop="$emit('edit-token', token.type)"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
:style="{ backgroundColor: getTokenColor(token.type) }"
>
<Icon :name="getTokenIcon(token.type)" size="12" class="text-white" />
</span>
<span class="max-w-28 truncate font-medium text-sm">{{ token.label }}</span>
<button
class="hover:text-error ml-0.5"
@click.stop="$emit('remove-token', token.type)"
>
<Icon name="lucide:x" size="14" />
</button>
</div>
<!-- Search input -->
<input
ref="inputRef"
v-model="localSearchQuery"
type="text"
:placeholder="placeholder"
class="flex-1 min-w-32 bg-transparent outline-none text-lg text-base-content placeholder:text-base-content/50"
@input="$emit('update:search-query', localSearchQuery)"
/>
</div>
</div>
</template>
</div>
<!-- Right: Globe + Team + User (top aligned like logo) -->
<div class="flex items-center flex-shrink-0 rounded-full glass-bright">
<div class="w-px h-6 bg-white/20 self-center" />
<div class="flex items-center px-2 py-2">
<!-- Globe (language/currency) dropdown -->
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon name="lucide:globe" size="18" />
</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 class="font-semibold mb-2">{{ $t('common.language') }}</div>
<div class="flex gap-2 mb-4">
<NuxtLink
v-for="loc in locales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
class="btn btn-sm"
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</div>
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
<button
class="btn btn-sm btn-ghost w-full justify-start"
@click="$emit('toggle-theme')"
>
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
</button>
</div>
</div>
</div>
<!-- Team dropdown -->
<div v-if="loggedIn && userData?.teams?.length" class="w-px h-6 bg-white/20 self-center" />
<div v-if="loggedIn && userData?.teams?.length" class="flex items-center px-2 py-2">
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors"
:class="useWhiteText ? 'text-white/70 hover:text-white hover:bg-white/10' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
>
<Icon name="lucide:building-2" size="16" />
<span class="hidden lg:inline max-w-24 truncate text-xs">
{{ userData?.activeTeam?.name || $t('common.selectTeam') }}
</span>
<Icon name="lucide:chevron-down" size="12" />
</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">
<li class="menu-title"><span>{{ $t('common.teams') }}</span></li>
<li v-for="team in userData?.teams" :key="team.id">
<a
@click="$emit('switch-team', team)"
:class="{ active: team.id === userData?.activeTeamId }"
>
{{ team.name }}
</a>
</li>
<div class="divider my-1"></div>
<li>
<NuxtLink :to="localePath('/clientarea/team')">
<Icon name="lucide:settings" size="16" />
{{ $t('cabinetNav.teamSettings') }}
</NuxtLink>
</li>
</ul>
</div>
</div>
<!-- User menu -->
<div v-if="sessionChecked" class="w-px h-6 bg-white/20 self-center" />
<div v-if="sessionChecked" class="flex items-center px-2 py-2">
<template v-if="loggedIn">
<div class="dropdown dropdown-end">
<div
tabindex="0"
role="button"
class="w-8 h-8 rounded-full overflow-hidden ring-2 transition-all cursor-pointer"
:class="useWhiteText ? 'ring-white/20 hover:ring-white/40' : 'ring-base-300 hover:ring-primary'"
>
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
<div
v-else
class="w-full h-full flex items-center justify-center font-bold text-xs"
:class="useWhiteText ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content'"
>
{{ userInitials }}
</div>
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300"
>
<li class="menu-title">
<span>{{ userName }}</span>
</li>
<li>
<NuxtLink :to="localePath('/clientarea/profile')">
<Icon name="lucide:user" size="16" />
{{ $t('dashboard.profile') }}
</NuxtLink>
</li>
<div class="divider my-1"></div>
<li>
<a @click="$emit('sign-out')" class="text-error">
<Icon name="lucide:log-out" size="16" />
{{ $t('auth.logout') }}
</a>
</li>
</ul>
</div>
</template>
<template v-else>
<button
@click="$emit('sign-in')"
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors"
:class="useWhiteText ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-primary text-primary-content hover:bg-primary-focus'"
>
{{ $t('auth.login') }}
</button>
</template>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type { SelectMode } from '~/composables/useCatalogSearch'
import { entityColors } from '~/composables/useCatalogSearch'
import type { CatalogMode } from '~/composables/useCatalogSearch'
const props = withDefaults(defineProps<{
sessionChecked?: boolean
loggedIn?: boolean
userAvatarSvg?: string
userName?: string
userInitials?: string
theme?: 'cupcake' | 'night'
userData?: {
id?: string
activeTeam?: { name?: string; teamType?: string }
activeTeamId?: string
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
} | null
isSeller?: boolean
// Role switching props
hasMultipleRoles?: boolean
currentRole?: string
// Search props
activeTokens?: Array<{ type: string; id: string; label: string; icon: string }>
availableChips?: Array<{ type: string; label: string }>
selectMode?: SelectMode
searchQuery?: string
// Catalog mode props
catalogMode?: CatalogMode
// Quote mode props
productLabel?: string
hubLabel?: string
quantity?: string
canSearch?: boolean
showModeToggle?: boolean
showActiveMode?: boolean // Whether to show active state on mode toggle
// Glass style applied when header is collapsed
isCollapsed?: boolean
// Home page flag for transparent background
isHomePage?: boolean
// Client area flag - shows cabinet tabs instead of search
isClientArea?: boolean
// AI chat sidebar state
chatOpen?: boolean
// Dynamic height for hero effect
height?: number
// Collapse progress for hero layout
collapseProgress?: number
}>(), {
height: 100,
collapseProgress: 1
})
defineEmits([
'toggle-chat',
'toggle-theme',
'sign-out',
'sign-in',
'switch-team',
'switch-role',
// Search events
'start-select',
'cancel-select',
'edit-token',
'remove-token',
'update:search-query',
'update-quantity',
// Quote mode
'search',
'set-catalog-mode'
])
const localePath = useLocalePath()
const route = useRoute()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const { t } = useI18n()
const { chatOpen } = toRefs(props)
// Check if client area tab is active
const isClientAreaTabActive = (path: string) => {
const currentPath = route.path
const localizedPath = localePath(path)
return currentPath === localizedPath || currentPath.startsWith(localizedPath + '/')
}
const inputRef = ref<HTMLInputElement>()
const localSearchQuery = ref(props.searchQuery || '')
const localQuantity = ref(props.quantity || '')
watch(() => props.searchQuery, (val) => {
localSearchQuery.value = val || ''
})
watch(() => props.quantity, (val) => {
localQuantity.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) 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 getTokenColor = (type: string) => {
return entityColors[type as keyof typeof entityColors] || entityColors.product
}
const getTokenIcon = (type: string) => {
const icons: Record<string, string> = {
product: 'lucide:shopping-bag',
hub: 'lucide:warehouse',
supplier: 'lucide:factory'
}
return icons[type] || 'lucide:tag'
}
const isHeroLayout = computed(() => props.isHomePage && !props.isClientArea)
const topRowHeight = 100
const rowStyle = computed(() => {
if (isHeroLayout.value) {
return { height: `${topRowHeight}px` }
}
return { height: `${props.height}px` }
})
const glassStyle = computed(() => {
if (isHeroLayout.value) {
return { height: `${topRowHeight}px` }
}
return { height: '100%' }
})
const centerStyle = computed(() => {
if (!isHeroLayout.value) return {}
const heroHeight = props.height || topRowHeight
const minTop = 0
const maxTop = Math.max(120, Math.round(heroHeight * 0.42))
const progress = Math.min(1, Math.max(0, props.collapseProgress || 0))
const top = Math.round(maxTop - (maxTop - minTop) * progress)
return { marginTop: `${top}px` }
})
// Header background classes
const headerClasses = computed(() => {
if (props.isHomePage && !props.isCollapsed) {
return 'bg-transparent'
}
if (props.isCollapsed) {
return 'bg-transparent backdrop-blur-xl'
}
return 'bg-transparent backdrop-blur-xl'
})
// Use white text on dark backgrounds (collapsed or home page with animation)
const useWhiteText = computed(() => props.isCollapsed || props.isHomePage)
</script>