- Add cabinet layout with 1/6 sidebar - Header: rename "Мои заказы" to "Личный кабинет" - Add cabinet pages: orders, offers (seller only), new offer - TeamCreateForm: add team type selection (BUYER/SELLER) - Sidebar shows "Мои предложения" only for SELLER teams 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
300 lines
13 KiB
Vue
300 lines
13 KiB
Vue
<template>
|
||
<header class="sticky top-0 z-50 pb-4">
|
||
<div class="max-w-content mx-auto bg-gradient-to-r from-primary to-primary-hover rounded-b-medium px-4 sm:px-6 lg:px-8">
|
||
<div class="flex items-center justify-between h-[100px]">
|
||
<!-- Logo and Mobile Menu Button -->
|
||
<div class="flex items-center space-x-3 sm:space-x-4">
|
||
<!-- Mobile Menu Button -->
|
||
<button
|
||
@click="$emit('toggle-sidebar')"
|
||
class="lg:hidden p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||
>
|
||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<NuxtLink :to="localePath('/')" class="text-2xl sm:text-3xl font-bold text-white">
|
||
Optovia
|
||
</NuxtLink>
|
||
|
||
<NuxtLink
|
||
:to="localePath('/catalog')"
|
||
class="hidden sm:inline-flex items-center gap-2 px-4 py-2 text-white border border-white/50 rounded-small hover:bg-white/10 transition-colors"
|
||
>
|
||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||
</svg>
|
||
Каталог
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- Global Search -->
|
||
<div class="hidden sm:flex flex-1 items-center gap-4 mx-4 lg:mx-8">
|
||
<div class="relative flex-1 max-w-lg">
|
||
<input
|
||
ref="searchInput"
|
||
type="text"
|
||
:placeholder="$t('search.placeholder') || 'Поиск... (Ctrl+K)'"
|
||
v-model="searchQuery"
|
||
class="w-full px-5 py-3 pl-12 pr-16 text-base text-gray-700 bg-white border border-white/20 rounded-small focus:outline-none focus:border-white transition-colors"
|
||
@keydown.cmd.k.prevent="focusSearch"
|
||
@keydown.ctrl.k.prevent="focusSearch"
|
||
@focus="showCommandPalette = true"
|
||
@blur="hideCommandPalette"
|
||
/>
|
||
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||
</svg>
|
||
<kbd class="absolute right-4 top-1/2 -translate-y-1/2 px-2 py-1 text-xs font-semibold text-gray-400 bg-gray-100 border border-gray-400/30 rounded-small">
|
||
⌘K
|
||
</kbd>
|
||
|
||
<!-- Command Palette Dropdown -->
|
||
<div v-if="showCommandPalette" class="absolute top-full left-0 right-0 mt-1 bg-white rounded-small shadow-hover border border-gray-100 py-2 z-50 max-h-96 overflow-y-auto">
|
||
<!-- Recent Orders -->
|
||
<div class="px-3 py-2">
|
||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Последние заказы</p>
|
||
<div class="space-y-1">
|
||
<button class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Кофе арабика зеленый 213т → Eldoret Agricultural Center
|
||
</button>
|
||
<button class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Сталь 150т → Moscow Distribution Center
|
||
</button>
|
||
<button class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Алюминий 75т → Novorossiysk Port
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-2 border-gray-100">
|
||
|
||
<!-- Quick Actions -->
|
||
<div class="px-3 py-2">
|
||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Быстрые действия</p>
|
||
<div class="space-y-1">
|
||
<button @click="navigateToAction('/dashboard/goods')" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Найти материалы
|
||
</button>
|
||
<button @click="navigateToAction('/dashboard/orders')" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Мои заказы
|
||
</button>
|
||
<button @click="navigateToAction('/dashboard/profile')" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Настройки профиля
|
||
</button>
|
||
<button @click="navigateToAction('/dashboard/team')" class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-small transition-colors">
|
||
Управление командой
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<NuxtLink
|
||
:to="localePath('/dashboard/cabinet')"
|
||
class="text-base font-medium text-white/80 hover:text-white transition-colors"
|
||
>
|
||
Личный кабинет
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-3">
|
||
<!-- Auth section - рендерим только на клиенте чтобы избежать hydration mismatch -->
|
||
<ClientOnly>
|
||
<template v-if="sessionChecked">
|
||
<!-- Залогинен: уведомления и меню пользователя -->
|
||
<template v-if="loggedIn">
|
||
<NovuNotificationBell
|
||
v-if="novuSubscriberId"
|
||
:subscriber-id="novuSubscriberId"
|
||
/>
|
||
<!-- User Menu -->
|
||
<div class="relative">
|
||
<button
|
||
@click="toggleUserMenu"
|
||
class="flex items-center space-x-2 px-4 py-2.5 text-base font-medium text-white bg-white/10 border border-white/20 rounded-small hover:bg-white/20 transition-colors"
|
||
>
|
||
<div class="w-8 h-8 rounded-full overflow-hidden">
|
||
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
||
<div v-else class="w-full h-full bg-blue-600 flex items-center justify-center text-white text-sm font-bold">
|
||
{{ (userData?.firstName?.charAt(0) || '') + (userData?.lastName?.charAt(0) || '') || (user?.email || '?').charAt(0).toUpperCase() }}
|
||
</div>
|
||
</div>
|
||
<span class="hidden sm:inline text-white">{{ userData?.firstName || user?.email?.split('@')[0] || '' }}</span>
|
||
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<!-- Dropdown Menu -->
|
||
<div v-show="showUserMenu" class="absolute right-0 mt-2 w-48 bg-white rounded-small shadow-hover border border-gray-100 py-2 z-50">
|
||
<NuxtLink
|
||
:to="localePath('/dashboard/profile')"
|
||
@click="showUserMenu = false"
|
||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||
>
|
||
{{ $t('dashboard.profile') }}
|
||
</NuxtLink>
|
||
<NuxtLink
|
||
:to="localePath('/dashboard/team')"
|
||
@click="showUserMenu = false"
|
||
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 transition-colors"
|
||
>
|
||
{{ userData?.activeTeam?.name || $t('teams.create_first_team') }}
|
||
</NuxtLink>
|
||
<hr class="my-1 border-gray-100">
|
||
<button
|
||
@click="onClickSignOut"
|
||
class="w-full text-left px-4 py-2 text-sm text-red hover:bg-gray-100 transition-colors"
|
||
>
|
||
{{ $t('auth.logout') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Не залогинен: кнопки входа -->
|
||
<template v-else>
|
||
<button
|
||
@click="signIn()"
|
||
class="px-6 py-3 text-base font-semibold text-primary bg-white rounded-small hover:bg-white/90 transition-colors"
|
||
>
|
||
{{ $t('auth.login') }}
|
||
</button>
|
||
<a
|
||
href="https://auth.optovia.ru/register"
|
||
class="hidden sm:block px-6 py-3 text-base font-medium text-white/80 hover:text-white transition-colors"
|
||
>
|
||
{{ $t('auth.register') }}
|
||
</a>
|
||
</template>
|
||
</template>
|
||
</ClientOnly>
|
||
|
||
<LangSwitcher />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
</template>
|
||
|
||
<script setup>
|
||
|
||
defineEmits(['toggle-sidebar'])
|
||
|
||
const localePath = useLocalePath()
|
||
const runtimeConfig = useRuntimeConfig()
|
||
const siteUrl = runtimeConfig.public.siteUrl || 'https://optovia.ru/'
|
||
const { signIn, signOut, loggedIn, getIdTokenClaims, fetch: fetchSession } = useAuth()
|
||
|
||
const user = ref()
|
||
const userData = ref()
|
||
const showUserMenu = ref(false)
|
||
const sessionChecked = ref(false)
|
||
const searchQuery = ref('')
|
||
const searchInput = ref(null)
|
||
const showCommandPalette = ref(false)
|
||
const userAvatarSvg = ref('')
|
||
const novuSubscriberId = ref('')
|
||
|
||
// Генерация аватара для хедера
|
||
const generateUserAvatar = async () => {
|
||
const seed = userData.value?.avatarId || userData.value?.id || userData.value?.firstName || 'default'
|
||
try {
|
||
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
|
||
if (response.ok) {
|
||
userAvatarSvg.value = await response.text()
|
||
}
|
||
} catch (error) {
|
||
console.error('Error generating avatar:', error)
|
||
}
|
||
}
|
||
|
||
const computeNovuSubscriber = () => {
|
||
// Prefer explicit IDs from Logto claims; fallback to user record.
|
||
const claimId = user.value?.sub || user.value?.user_id || userData.value?.id
|
||
const email = user.value?.email
|
||
if (claimId) return claimId
|
||
if (email) return email
|
||
return ''
|
||
}
|
||
|
||
const { setActiveTeam } = useActiveTeam()
|
||
const { execute } = useGraphQL()
|
||
|
||
// Загружаем данные пользователя
|
||
const loadUserData = async () => {
|
||
const claims = await getIdTokenClaims()
|
||
user.value = claims
|
||
novuSubscriberId.value = computeNovuSubscriber()
|
||
|
||
try {
|
||
const { GetMeDocument } = await import('~/composables/graphql/user/teams-generated')
|
||
const result = await execute(GetMeDocument, {}, 'user', 'teams')
|
||
|
||
if (result.me) {
|
||
userData.value = result.me
|
||
// Устанавливаем activeTeam для получения organization token
|
||
if (result.me.activeTeamId && result.me.activeTeam?.logtoOrgId) {
|
||
setActiveTeam(result.me.activeTeamId, result.me.activeTeam.logtoOrgId)
|
||
}
|
||
await generateUserAvatar()
|
||
novuSubscriberId.value = computeNovuSubscriber()
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load user data:', err)
|
||
userData.value = null
|
||
}
|
||
}
|
||
|
||
// Проверяем сессию и настраиваем обработчики событий
|
||
onMounted(async () => {
|
||
// Проверяем сессию
|
||
await fetchSession().catch(() => {})
|
||
sessionChecked.value = true
|
||
if (loggedIn.value) {
|
||
await loadUserData()
|
||
}
|
||
|
||
// Закрываем меню при клике вне его
|
||
document.addEventListener('click', (e) => {
|
||
if (!e.target?.closest('.relative')) {
|
||
showUserMenu.value = false
|
||
}
|
||
})
|
||
|
||
// Keyboard shortcut for search
|
||
document.addEventListener('keydown', (e) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||
e.preventDefault()
|
||
focusSearch()
|
||
}
|
||
})
|
||
})
|
||
|
||
const toggleUserMenu = () => {
|
||
showUserMenu.value = !showUserMenu.value
|
||
}
|
||
|
||
const focusSearch = () => {
|
||
searchInput.value?.focus()
|
||
}
|
||
|
||
const hideCommandPalette = () => {
|
||
setTimeout(() => {
|
||
showCommandPalette.value = false
|
||
}, 150) // Delay чтобы клик по dropdown сработал
|
||
}
|
||
|
||
const navigateToAction = (path) => {
|
||
showCommandPalette.value = false
|
||
navigateTo(localePath(path))
|
||
}
|
||
|
||
const onClickSignOut = () => {
|
||
showUserMenu.value = false
|
||
signOut(siteUrl)
|
||
}
|
||
</script>
|