Files
optovia/webapp/app/components/Header.vue
Ruslan Bakiev 19c5a1e60f Add personal cabinet with team type (buyer/seller)
- 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>
2025-12-08 16:55:55 +07:00

300 lines
13 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="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>