Files
webapp/app/components/navigation/MainNavigation.vue
Ruslan Bakiev 2b6cccdead
All checks were successful
Build Docker Image / build (push) Successful in 5m8s
Fix all TypeScript errors and remove Storybook
- Remove all Storybook files and configuration
- Add type declarations for @vueuse/core, @formkit/core, vue3-apexcharts
- Fix TypeScript configuration (typeRoots, include paths)
- Fix Sentry config - move settings to plugin
- Fix nullable prop assignments with ?? operator
- Fix type narrowing issues with explicit type assertions
- Fix Card component linkable computed properties
- Update codegen with operationResultSuffix
- Fix GraphQL operation type definitions
2026-01-26 00:32:36 +07:00

403 lines
16 KiB
Vue

<template>
<header
class="h-[100px] shadow-lg"
:class="glassStyle ? 'bg-black/30 backdrop-blur-md border-b border-white/10' : 'bg-base-100 border-b border-base-300'"
>
<!-- Single row: Logo + Search + Icons -->
<div class="flex items-stretch h-full px-4 lg:px-6 gap-4">
<!-- Left: Logo + Nav links (top aligned) -->
<div class="flex items-start gap-6 flex-shrink-0 pt-4">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<span class="font-bold text-xl" :class="glassStyle ? 'text-white' : 'text-base-content'">Optovia</span>
</NuxtLink>
<!-- Service nav links -->
<nav v-if="showModeToggle" class="flex items-center gap-1">
<button
class="px-3 py-1 text-sm font-medium rounded-lg transition-colors"
:class="catalogMode === 'explore'
? (glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (glassStyle ? '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-lg transition-colors"
:class="catalogMode === 'quote'
? (glassStyle ? 'bg-white/20 text-white' : 'bg-base-300 text-base-content')
: (glassStyle ? '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>
</nav>
</div>
<!-- Center: Search input (vertically centered) -->
<div class="flex-1 flex flex-col items-center max-w-2xl mx-auto gap-2 justify-center">
<!-- Quote mode: Simple segmented input with search inside (white glass) -->
<template v-if="catalogMode === 'quote'">
<div class="flex items-center w-full rounded-full border border-white/40 bg-white/80 backdrop-blur-md shadow-lg divide-x divide-base-300/30">
<!-- 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>
<!-- 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>
<!-- 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 border border-white/40 bg-white/80 backdrop-blur-md shadow-lg 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>
<!-- Chips below (with colored circle icons) -->
<div
v-if="availableChips?.length"
class="flex items-center justify-center gap-2"
>
<button
v-for="chip in availableChips"
:key="chip.type"
class="flex items-center gap-1.5 px-3 py-1 rounded-full text-sm bg-white/60 text-base-content/80 hover:bg-white/80 hover:text-base-content transition-colors"
@click="$emit('start-select', chip.type)"
>
<span
class="w-5 h-5 rounded-full flex items-center justify-center flex-shrink-0"
:style="{ backgroundColor: getTokenColor(chip.type) }"
>
<Icon :name="getTokenIcon(chip.type)" size="12" class="text-white" />
</span>
{{ chip.label }}
</button>
</div>
</template>
</div>
<!-- Right: AI + Globe + Team + User (top aligned like logo) -->
<div class="flex items-start gap-1 flex-shrink-0 pt-4">
<!-- AI Assistant button -->
<NuxtLink
:to="localePath('/clientarea/ai')"
class="w-8 h-8 rounded-full flex items-center justify-center transition-colors"
:class="glassStyle ? '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:bot" size="18" />
</NuxtLink>
<!-- 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="glassStyle ? '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>
<!-- Team dropdown -->
<template v-if="loggedIn && userData?.teams?.length">
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="h-8 flex items-center gap-1 px-2 rounded-lg transition-colors"
:class="glassStyle ? '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>
</template>
<!-- User menu -->
<template v-if="sessionChecked">
<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="glassStyle ? '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="glassStyle ? '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="glassStyle ? 'bg-white/20 text-white hover:bg-white/30' : 'bg-primary text-primary-content hover:bg-primary-focus'"
>
{{ $t('auth.login') }}
</button>
</template>
</template>
</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 = 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
// 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
// Glass style (transparent) for map pages
glassStyle?: boolean
}>()
defineEmits([
'toggle-theme',
'sign-out',
'sign-in',
'switch-team',
// 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 { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const { t } = useI18n()
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'
}
</script>