Add topnav layout with MainNavigation, SubNavigation, GlobalSearchBar, SplitLayout
Some checks failed
Build Docker Image / build (push) Failing after 1m25s
Some checks failed
Build Docker Image / build (push) Failing after 1m25s
This commit is contained in:
49
app/components/layout/SplitLayout.vue
Normal file
49
app/components/layout/SplitLayout.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex-1 flex flex-col lg:flex-row min-h-0">
|
||||||
|
<!-- List panel -->
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-auto lg:w-3/5 lg:max-w-none"
|
||||||
|
:class="{ 'hidden lg:block': mobileView === 'map' }"
|
||||||
|
>
|
||||||
|
<slot name="list" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map panel -->
|
||||||
|
<div
|
||||||
|
class="lg:w-2/5 lg:sticky lg:top-0 lg:h-[calc(100vh-13rem)]"
|
||||||
|
:class="{ 'hidden lg:block': mobileView === 'list', 'flex-1': mobileView === 'map' }"
|
||||||
|
>
|
||||||
|
<slot name="map" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile toggle -->
|
||||||
|
<div class="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
|
||||||
|
<div class="btn-group shadow-lg">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="{ 'btn-active': mobileView === 'list' }"
|
||||||
|
@click="mobileView = 'list'"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:list" size="16" />
|
||||||
|
{{ $t('common.list') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="{ 'btn-active': mobileView === 'map' }"
|
||||||
|
@click="mobileView = 'map'"
|
||||||
|
>
|
||||||
|
<Icon name="lucide:map" size="16" />
|
||||||
|
{{ $t('common.map') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultView?: 'list' | 'map'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mobileView = ref<'list' | 'map'>(props.defaultView || 'list')
|
||||||
|
</script>
|
||||||
217
app/components/navigation/MainNavigation.vue
Normal file
217
app/components/navigation/MainNavigation.vue
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
<template>
|
||||||
|
<header class="sticky top-0 z-40 bg-base-100 border-b border-base-300">
|
||||||
|
<div class="flex items-center justify-between h-16 px-4 lg:px-6">
|
||||||
|
<!-- Left: Logo -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
|
||||||
|
<img src="/logo.svg" alt="Optovia" class="h-8" />
|
||||||
|
<span class="font-bold text-xl hidden sm:inline">Optovia</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Center: Main tabs -->
|
||||||
|
<nav class="hidden md:flex items-center gap-1">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="tab in visibleTabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:to="localePath(tab.path)"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'tab-active': isActiveTab(tab.key) }"
|
||||||
|
>
|
||||||
|
<Icon v-if="tab.icon" :name="tab.icon" size="18" class="mr-1" />
|
||||||
|
{{ tab.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Right: Globe + Team + User -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Globe (language/currency) dropdown -->
|
||||||
|
<div class="dropdown dropdown-end">
|
||||||
|
<button tabindex="0" class="btn btn-ghost btn-circle">
|
||||||
|
<Icon name="lucide:globe" size="20" />
|
||||||
|
</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="btn btn-ghost gap-2">
|
||||||
|
<Icon name="lucide:building-2" size="18" />
|
||||||
|
<span class="hidden sm:inline max-w-32 truncate">
|
||||||
|
{{ userData?.activeTeam?.name || $t('common.selectTeam') }}
|
||||||
|
</span>
|
||||||
|
<Icon name="lucide:chevron-down" size="14" />
|
||||||
|
</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="btn btn-ghost btn-circle avatar">
|
||||||
|
<div class="w-10 rounded-full">
|
||||||
|
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-sm"
|
||||||
|
>
|
||||||
|
{{ userInitials }}
|
||||||
|
</div>
|
||||||
|
</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="btn btn-primary btn-sm">
|
||||||
|
{{ $t('auth.login') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile tabs (shown below header on small screens) -->
|
||||||
|
<nav class="md:hidden flex items-center justify-center gap-1 py-2 border-t border-base-300 overflow-x-auto">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="tab in visibleTabs"
|
||||||
|
:key="tab.key"
|
||||||
|
:to="localePath(tab.path)"
|
||||||
|
class="tab-item-mobile"
|
||||||
|
:class="{ 'tab-active': isActiveTab(tab.key) }"
|
||||||
|
>
|
||||||
|
<Icon v-if="tab.icon" :name="tab.icon" size="16" />
|
||||||
|
<span class="text-xs">{{ tab.label }}</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
sessionChecked?: boolean
|
||||||
|
loggedIn?: boolean
|
||||||
|
userAvatarSvg?: string
|
||||||
|
userName?: string
|
||||||
|
userInitials?: string
|
||||||
|
theme?: 'default' | 'night'
|
||||||
|
userData?: {
|
||||||
|
id?: string
|
||||||
|
activeTeam?: { name?: string; teamType?: string }
|
||||||
|
activeTeamId?: string
|
||||||
|
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
||||||
|
} | null
|
||||||
|
isSeller?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits(['toggle-theme', 'sign-out', 'sign-in', 'switch-team'])
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const { locale, locales } = useI18n()
|
||||||
|
const switchLocalePath = useSwitchLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const tabs = computed(() => [
|
||||||
|
{ key: 'catalog', label: t('cabinetNav.catalog'), path: '/catalog/offers', icon: 'lucide:search', auth: false },
|
||||||
|
{ key: 'orders', label: t('cabinetNav.orders'), path: '/clientarea/orders', icon: 'lucide:package', auth: true },
|
||||||
|
{ key: 'seller', label: t('cabinetNav.seller'), path: '/clientarea/offers', icon: 'lucide:store', auth: true, seller: true },
|
||||||
|
])
|
||||||
|
|
||||||
|
const visibleTabs = computed(() => {
|
||||||
|
return tabs.value.filter(tab => {
|
||||||
|
if (tab.auth && !props.loggedIn) return false
|
||||||
|
if (tab.seller && !props.isSeller) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const isActiveTab = (key: string) => {
|
||||||
|
const path = route.path
|
||||||
|
if (key === 'catalog') return path.startsWith('/catalog') || path === '/'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab-item {
|
||||||
|
@apply px-4 py-2 rounded-full font-medium text-sm transition-colors;
|
||||||
|
@apply hover:bg-base-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.tab-active {
|
||||||
|
@apply bg-base-200 text-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item-mobile {
|
||||||
|
@apply flex flex-col items-center gap-1 px-4 py-2 rounded-lg;
|
||||||
|
@apply hover:bg-base-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item-mobile.tab-active {
|
||||||
|
@apply bg-base-200 text-primary;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
app/components/navigation/SubNavigation.vue
Normal file
63
app/components/navigation/SubNavigation.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<template>
|
||||||
|
<nav v-if="items.length > 0" class="bg-base-100 border-b border-base-300">
|
||||||
|
<div class="flex items-center justify-center gap-1 py-2 px-4 overflow-x-auto">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.path"
|
||||||
|
:to="localePath(item.path)"
|
||||||
|
class="subnav-item"
|
||||||
|
:class="{ active: isActive(item.path) }"
|
||||||
|
>
|
||||||
|
<Icon v-if="item.icon" :name="item.icon" size="16" class="mr-1.5" />
|
||||||
|
{{ item.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
section: 'catalog' | 'orders' | 'seller' | 'settings'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const sectionItems = computed(() => ({
|
||||||
|
catalog: [
|
||||||
|
{ label: t('cabinetNav.offers'), path: '/catalog/offers', icon: 'lucide:tag' },
|
||||||
|
{ label: t('cabinetNav.suppliers'), path: '/catalog/suppliers', icon: 'lucide:users' },
|
||||||
|
{ label: t('cabinetNav.hubs'), path: '/catalog/hubs', icon: 'lucide:warehouse' },
|
||||||
|
],
|
||||||
|
orders: [
|
||||||
|
{ label: t('cabinetNav.orders'), path: '/clientarea/orders', icon: 'lucide:package' },
|
||||||
|
{ label: t('cabinetNav.addresses'), path: '/clientarea/addresses', icon: 'lucide:map-pin' },
|
||||||
|
{ label: t('cabinetNav.billing'), path: '/clientarea/billing', icon: 'lucide:credit-card' },
|
||||||
|
],
|
||||||
|
seller: [
|
||||||
|
{ label: t('cabinetNav.myOffers'), path: '/clientarea/offers', icon: 'lucide:tag' },
|
||||||
|
],
|
||||||
|
settings: [
|
||||||
|
{ label: t('cabinetNav.profile'), path: '/clientarea/profile', icon: 'lucide:user' },
|
||||||
|
{ label: t('cabinetNav.team'), path: '/clientarea/team', icon: 'lucide:users' },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const items = computed(() => sectionItems.value[props.section] || [])
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
return route.path === localePath(path) || route.path.startsWith(localePath(path) + '/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.subnav-item {
|
||||||
|
@apply px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap;
|
||||||
|
@apply text-base-content/70 hover:text-base-content hover:bg-base-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav-item.active {
|
||||||
|
@apply text-primary bg-primary/10;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
223
app/components/search/GlobalSearchBar.vue
Normal file
223
app/components/search/GlobalSearchBar.vue
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<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 -->
|
||||||
|
<div class="search-field border-r border-base-300">
|
||||||
|
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
|
||||||
|
{{ $t('search.product') }}
|
||||||
|
</label>
|
||||||
|
<div class="dropdown w-full">
|
||||||
|
<input
|
||||||
|
v-model="productQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('search.product_placeholder')"
|
||||||
|
class="w-full bg-transparent outline-none text-sm"
|
||||||
|
@focus="showProductDropdown = true"
|
||||||
|
@input="filterProducts"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="showProductDropdown && filteredProducts.length > 0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-64 max-h-60 overflow-auto p-2 shadow-lg border border-base-300 mt-2"
|
||||||
|
>
|
||||||
|
<li v-for="product in filteredProducts" :key="product.uuid">
|
||||||
|
<a @click="selectProduct(product)">
|
||||||
|
{{ product.name }}
|
||||||
|
<span class="text-xs text-base-content/50">{{ product.categoryName }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quantity field -->
|
||||||
|
<div class="search-field border-r border-base-300">
|
||||||
|
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
|
||||||
|
{{ $t('search.quantity') }}
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
v-model="quantity"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:placeholder="$t('search.quantity_placeholder')"
|
||||||
|
class="w-16 bg-transparent outline-none text-sm"
|
||||||
|
/>
|
||||||
|
<select v-model="unit" class="bg-transparent outline-none text-sm text-base-content/70">
|
||||||
|
<option value="t">{{ $t('units.t') }}</option>
|
||||||
|
<option value="kg">{{ $t('units.kg') }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Destination field -->
|
||||||
|
<div class="search-field">
|
||||||
|
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
|
||||||
|
{{ $t('search.destination') }}
|
||||||
|
</label>
|
||||||
|
<div class="dropdown w-full">
|
||||||
|
<input
|
||||||
|
v-model="destinationQuery"
|
||||||
|
type="text"
|
||||||
|
:placeholder="$t('search.destination_placeholder')"
|
||||||
|
class="w-full bg-transparent outline-none text-sm"
|
||||||
|
@focus="showDestinationDropdown = true"
|
||||||
|
@input="searchDestinations"
|
||||||
|
/>
|
||||||
|
<ul
|
||||||
|
v-if="showDestinationDropdown && destinations.length > 0"
|
||||||
|
class="dropdown-content menu bg-base-100 rounded-box z-50 w-64 max-h-60 overflow-auto p-2 shadow-lg border border-base-300 mt-2"
|
||||||
|
>
|
||||||
|
<li v-for="dest in destinations" :key="dest.uuid">
|
||||||
|
<a @click="selectDestination(dest)">
|
||||||
|
{{ dest.name }}
|
||||||
|
<span class="text-xs text-base-content/50">{{ dest.country }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</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; unit?: string; destinationUuid?: string }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
// Product search
|
||||||
|
const productQuery = ref('')
|
||||||
|
const selectedProduct = ref<{ uuid: string; name: string; categoryName?: string } | null>(null)
|
||||||
|
const showProductDropdown = ref(false)
|
||||||
|
const allProducts = ref<Array<{ uuid: string; name: string; categoryName?: string }>>([])
|
||||||
|
|
||||||
|
const filteredProducts = computed(() => {
|
||||||
|
if (!productQuery.value) return allProducts.value.slice(0, 10)
|
||||||
|
const q = productQuery.value.toLowerCase()
|
||||||
|
return allProducts.value.filter(p =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.categoryName?.toLowerCase().includes(q)
|
||||||
|
).slice(0, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filterProducts = () => {
|
||||||
|
showProductDropdown.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectProduct = (product: typeof selectedProduct.value) => {
|
||||||
|
selectedProduct.value = product
|
||||||
|
productQuery.value = product?.name || ''
|
||||||
|
showProductDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantity
|
||||||
|
const quantity = ref<number | undefined>()
|
||||||
|
const unit = ref('t')
|
||||||
|
|
||||||
|
// Destination search
|
||||||
|
const destinationQuery = ref('')
|
||||||
|
const selectedDestination = ref<{ uuid: string; name: string; country?: string } | null>(null)
|
||||||
|
const showDestinationDropdown = ref(false)
|
||||||
|
const destinations = ref<Array<{ uuid: string; name: string; country?: string }>>([])
|
||||||
|
|
||||||
|
const searchDestinations = async () => {
|
||||||
|
showDestinationDropdown.value = true
|
||||||
|
// TODO: implement destination search via GraphQL
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectDestination = (dest: typeof selectedDestination.value) => {
|
||||||
|
selectedDestination.value = dest
|
||||||
|
destinationQuery.value = dest?.name || ''
|
||||||
|
showDestinationDropdown.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Can search
|
||||||
|
const canSearch = computed(() => {
|
||||||
|
return !!selectedProduct.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Search handler
|
||||||
|
const handleSearch = () => {
|
||||||
|
if (!canSearch.value) return
|
||||||
|
|
||||||
|
const params: Record<string, string> = {}
|
||||||
|
if (selectedProduct.value?.uuid) {
|
||||||
|
params.product = selectedProduct.value.uuid
|
||||||
|
}
|
||||||
|
if (quantity.value) {
|
||||||
|
params.quantity = String(quantity.value)
|
||||||
|
params.unit = unit.value
|
||||||
|
}
|
||||||
|
if (selectedDestination.value?.uuid) {
|
||||||
|
params.destination = selectedDestination.value.uuid
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push({
|
||||||
|
path: localePath('/catalog/offers'),
|
||||||
|
query: params
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('search', {
|
||||||
|
productUuid: selectedProduct.value?.uuid,
|
||||||
|
quantity: quantity.value,
|
||||||
|
unit: unit.value,
|
||||||
|
destinationUuid: selectedDestination.value?.uuid
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load products on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const { query } = useGraphQL()
|
||||||
|
const { GetProductsDocument } = await import('~/composables/graphql/exchange/products-generated')
|
||||||
|
const result = await query(GetProductsDocument, {}, 'exchange', 'public')
|
||||||
|
allProducts.value = result.getProducts || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load products:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close dropdowns on click outside
|
||||||
|
onMounted(() => {
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (!target.closest('.dropdown')) {
|
||||||
|
showProductDropdown.value = false
|
||||||
|
showDestinationDropdown.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('click', handler)
|
||||||
|
onUnmounted(() => document.removeEventListener('click', handler))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.search-field {
|
||||||
|
@apply flex flex-col px-4 py-2 min-w-32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field:first-child {
|
||||||
|
@apply pl-6 rounded-l-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field:hover {
|
||||||
|
@apply bg-base-200/50;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
181
app/layouts/topnav.vue
Normal file
181
app/layouts/topnav.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col bg-base-200">
|
||||||
|
<!-- Main Navigation (Logo + Tabs + User) -->
|
||||||
|
<MainNavigation
|
||||||
|
:session-checked="sessionChecked"
|
||||||
|
:logged-in="isLoggedIn"
|
||||||
|
:user-avatar-svg="userAvatarSvg"
|
||||||
|
:user-name="userName"
|
||||||
|
:user-initials="userInitials"
|
||||||
|
:theme="theme"
|
||||||
|
:user-data="userData"
|
||||||
|
:is-seller="isSeller"
|
||||||
|
@toggle-theme="toggleTheme"
|
||||||
|
@sign-out="onClickSignOut"
|
||||||
|
@sign-in="signIn()"
|
||||||
|
@switch-team="switchToTeam"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Sub Navigation (section-specific tabs) -->
|
||||||
|
<SubNavigation :section="currentSection" />
|
||||||
|
|
||||||
|
<!-- Global Search Bar -->
|
||||||
|
<GlobalSearchBar v-if="showSearch" class="border-b border-base-300" />
|
||||||
|
|
||||||
|
<!-- Page content -->
|
||||||
|
<main class="flex-1 flex flex-col min-h-0">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const siteUrl = runtimeConfig.public.siteUrl || 'https://optovia.ru/'
|
||||||
|
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// Theme state
|
||||||
|
const theme = useState<'default' | 'night'>('theme', () => 'default')
|
||||||
|
|
||||||
|
// User data state (shared across layouts)
|
||||||
|
const userData = useState<{
|
||||||
|
id?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
avatarId?: string
|
||||||
|
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: any }
|
||||||
|
activeTeamId?: string
|
||||||
|
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
|
||||||
|
} | null>('me', () => null)
|
||||||
|
|
||||||
|
const sessionChecked = ref(false)
|
||||||
|
const userAvatarSvg = useState('user-avatar-svg', () => '')
|
||||||
|
const lastAvatarSeed = useState('user-avatar-seed', () => '')
|
||||||
|
|
||||||
|
const isSeller = computed(() => {
|
||||||
|
return userData.value?.activeTeam?.teamType === 'SELLER'
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => loggedIn.value || !!userData.value?.id)
|
||||||
|
|
||||||
|
const userName = computed(() => {
|
||||||
|
return userData.value?.firstName || 'User'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
const first = userData.value?.firstName?.charAt(0) || ''
|
||||||
|
const last = userData.value?.lastName?.charAt(0) || ''
|
||||||
|
if (first || last) return (first + last).toUpperCase()
|
||||||
|
return '?'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Determine current section from route
|
||||||
|
const currentSection = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
if (path.startsWith('/catalog') || path === '/') return 'catalog'
|
||||||
|
if (path.includes('/clientarea/offers')) return 'seller'
|
||||||
|
if (path.includes('/clientarea/orders') || path.includes('/clientarea/addresses') || path.includes('/clientarea/billing')) return 'orders'
|
||||||
|
if (path.includes('/clientarea')) return 'settings'
|
||||||
|
return 'catalog'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show search bar on catalog pages
|
||||||
|
const showSearch = computed(() => {
|
||||||
|
return currentSection.value === 'catalog'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Avatar generation
|
||||||
|
const generateUserAvatar = async (seed: string) => {
|
||||||
|
if (!seed) return
|
||||||
|
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()
|
||||||
|
lastAvatarSeed.value = seed
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating avatar:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { setActiveTeam } = useActiveTeam()
|
||||||
|
const { mutate } = useGraphQL()
|
||||||
|
const locationStore = useLocationStore()
|
||||||
|
|
||||||
|
const syncUserUi = async () => {
|
||||||
|
if (!userData.value) {
|
||||||
|
locationStore.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userData.value.activeTeamId && userData.value.activeTeam?.logtoOrgId) {
|
||||||
|
setActiveTeam(userData.value.activeTeamId, userData.value.activeTeam.logtoOrgId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = userData.value.avatarId || userData.value.id || userData.value.firstName || 'default'
|
||||||
|
|
||||||
|
if (!userAvatarSvg.value || lastAvatarSeed.value !== seed) {
|
||||||
|
userAvatarSvg.value = ''
|
||||||
|
await generateUserAvatar(seed)
|
||||||
|
}
|
||||||
|
|
||||||
|
locationStore.setFromUserData(userData.value.activeTeam?.selectedLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(userData, () => {
|
||||||
|
void syncUserUi()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
// Check session
|
||||||
|
await fetchSession().catch(() => {})
|
||||||
|
sessionChecked.value = true
|
||||||
|
|
||||||
|
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string }) => {
|
||||||
|
if (!team?.id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { SwitchTeamDocument } = await import('~/composables/graphql/user/teams-generated')
|
||||||
|
const result = await mutate(SwitchTeamDocument, { teamId: team.id }, 'user', 'teams')
|
||||||
|
|
||||||
|
if (result.switchTeam?.user && userData.value) {
|
||||||
|
userData.value.activeTeam = team as typeof userData.value.activeTeam
|
||||||
|
userData.value.activeTeamId = team.id
|
||||||
|
if (team.logtoOrgId) {
|
||||||
|
setActiveTeam(team.id, team.logtoOrgId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to switch team:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onClickSignOut = () => {
|
||||||
|
signOut(siteUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyTheme = (value: 'default' | 'night') => {
|
||||||
|
if (import.meta.client) {
|
||||||
|
if (value === 'default') {
|
||||||
|
document.documentElement.removeAttribute('data-theme')
|
||||||
|
} else {
|
||||||
|
document.documentElement.setAttribute('data-theme', value)
|
||||||
|
}
|
||||||
|
localStorage.setItem('theme', value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const stored = import.meta.client ? localStorage.getItem('theme') : null
|
||||||
|
if (stored === 'night' || stored === 'default') {
|
||||||
|
theme.value = stored
|
||||||
|
}
|
||||||
|
applyTheme(theme.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(theme, (value) => applyTheme(value))
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
theme.value = theme.value === 'night' ? 'default' : 'night'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
{
|
{
|
||||||
"cabinetNav": {
|
"cabinetNav": {
|
||||||
|
"catalog": "Catalog",
|
||||||
"orders": "My orders",
|
"orders": "My orders",
|
||||||
"addresses": "My addresses",
|
"addresses": "My addresses",
|
||||||
"billing": "Balance",
|
"billing": "Balance",
|
||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"team": "Company",
|
"team": "Company",
|
||||||
|
"teamSettings": "Team settings",
|
||||||
"offers": "My offers",
|
"offers": "My offers",
|
||||||
|
"myOffers": "My offers",
|
||||||
|
"seller": "Seller",
|
||||||
|
"suppliers": "Suppliers",
|
||||||
|
"hubs": "Hubs",
|
||||||
"ai": "AI assistant"
|
"ai": "AI assistant"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
"error": "Error",
|
"error": "Error",
|
||||||
"success": "Success",
|
"success": "Success",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
|
"back": "Back",
|
||||||
|
"list": "List",
|
||||||
|
"map": "Map",
|
||||||
|
"language": "Language",
|
||||||
|
"theme": "Theme",
|
||||||
|
"teams": "Teams",
|
||||||
|
"selectTeam": "Select team",
|
||||||
"actions": {
|
"actions": {
|
||||||
"load_more": "Load more"
|
"load_more": "Load more"
|
||||||
},
|
},
|
||||||
@@ -19,5 +26,9 @@
|
|||||||
"values": {
|
"values": {
|
||||||
"not_available": "Not available"
|
"not_available": "Not available"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"t": "t",
|
||||||
|
"kg": "kg"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
"title": "Find Raw Materials",
|
"title": "Find Raw Materials",
|
||||||
"subtitle": "& Industrial Services",
|
"subtitle": "& Industrial Services",
|
||||||
"description": "Connect with suppliers, get competitive prices, and streamline your procurement process",
|
"description": "Connect with suppliers, get competitive prices, and streamline your procurement process",
|
||||||
|
"product": "Product",
|
||||||
"product_label": "What are you looking for?",
|
"product_label": "What are you looking for?",
|
||||||
"product_placeholder": "Select material type",
|
"product_placeholder": "Select material type",
|
||||||
|
"quantity": "Quantity",
|
||||||
"quantity_label": "Quantity",
|
"quantity_label": "Quantity",
|
||||||
"quantity_placeholder": "Amount",
|
"quantity_placeholder": "Amount",
|
||||||
|
"destination": "Destination",
|
||||||
|
"destination_placeholder": "City or hub",
|
||||||
"location_label": "Delivery to",
|
"location_label": "Delivery to",
|
||||||
"location_placeholder": "City or region",
|
"location_placeholder": "City or region",
|
||||||
"search_button": "Search",
|
"search_button": "Search",
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
{
|
{
|
||||||
"cabinetNav": {
|
"cabinetNav": {
|
||||||
|
"catalog": "Каталог",
|
||||||
"orders": "Мои заказы",
|
"orders": "Мои заказы",
|
||||||
"addresses": "Мои адреса",
|
"addresses": "Мои адреса",
|
||||||
"billing": "Баланс",
|
"billing": "Баланс",
|
||||||
"profile": "Профиль",
|
"profile": "Профиль",
|
||||||
"team": "Компания",
|
"team": "Компания",
|
||||||
|
"teamSettings": "Настройки команды",
|
||||||
"offers": "Мои предложения",
|
"offers": "Мои предложения",
|
||||||
|
"myOffers": "Мои предложения",
|
||||||
|
"seller": "Продавец",
|
||||||
|
"suppliers": "Поставщики",
|
||||||
|
"hubs": "Хабы",
|
||||||
"ai": "AI ассистент"
|
"ai": "AI ассистент"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
"error": "Ошибка",
|
"error": "Ошибка",
|
||||||
"success": "Успех",
|
"success": "Успех",
|
||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
|
"back": "Назад",
|
||||||
|
"list": "Список",
|
||||||
|
"map": "Карта",
|
||||||
|
"language": "Язык",
|
||||||
|
"theme": "Тема",
|
||||||
|
"teams": "Команды",
|
||||||
|
"selectTeam": "Выбрать команду",
|
||||||
"actions": {
|
"actions": {
|
||||||
"load_more": "Показать еще"
|
"load_more": "Показать еще"
|
||||||
},
|
},
|
||||||
@@ -19,5 +26,9 @@
|
|||||||
"values": {
|
"values": {
|
||||||
"not_available": "Нет данных"
|
"not_available": "Нет данных"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"units": {
|
||||||
|
"t": "т",
|
||||||
|
"kg": "кг"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
"title": "Найти сырье",
|
"title": "Найти сырье",
|
||||||
"subtitle": "и промышленные сервисы",
|
"subtitle": "и промышленные сервисы",
|
||||||
"description": "Связывайтесь с поставщиками, получайте конкурентные цены и упрощайте процессы закупок",
|
"description": "Связывайтесь с поставщиками, получайте конкурентные цены и упрощайте процессы закупок",
|
||||||
|
"product": "Товар",
|
||||||
"product_label": "Что вы ищете?",
|
"product_label": "Что вы ищете?",
|
||||||
"product_placeholder": "Выберите тип материала",
|
"product_placeholder": "Выберите тип материала",
|
||||||
|
"quantity": "Количество",
|
||||||
"quantity_label": "Количество",
|
"quantity_label": "Количество",
|
||||||
"quantity_placeholder": "Объем",
|
"quantity_placeholder": "Объем",
|
||||||
|
"destination": "Куда",
|
||||||
|
"destination_placeholder": "Город или хаб",
|
||||||
"location_label": "Доставка в",
|
"location_label": "Доставка в",
|
||||||
"location_placeholder": "Город или регион",
|
"location_placeholder": "Город или регион",
|
||||||
"search_button": "Найти",
|
"search_button": "Найти",
|
||||||
|
|||||||
Reference in New Issue
Block a user