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": {
|
||||
"catalog": "Catalog",
|
||||
"orders": "My orders",
|
||||
"addresses": "My addresses",
|
||||
"billing": "Balance",
|
||||
"profile": "Profile",
|
||||
"team": "Company",
|
||||
"teamSettings": "Team settings",
|
||||
"offers": "My offers",
|
||||
"myOffers": "My offers",
|
||||
"seller": "Seller",
|
||||
"suppliers": "Suppliers",
|
||||
"hubs": "Hubs",
|
||||
"ai": "AI assistant"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"retry": "Retry",
|
||||
"back": "Back",
|
||||
"list": "List",
|
||||
"map": "Map",
|
||||
"language": "Language",
|
||||
"theme": "Theme",
|
||||
"teams": "Teams",
|
||||
"selectTeam": "Select team",
|
||||
"actions": {
|
||||
"load_more": "Load more"
|
||||
},
|
||||
@@ -19,5 +26,9 @@
|
||||
"values": {
|
||||
"not_available": "Not available"
|
||||
}
|
||||
},
|
||||
"units": {
|
||||
"t": "t",
|
||||
"kg": "kg"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
"title": "Find Raw Materials",
|
||||
"subtitle": "& Industrial Services",
|
||||
"description": "Connect with suppliers, get competitive prices, and streamline your procurement process",
|
||||
"product": "Product",
|
||||
"product_label": "What are you looking for?",
|
||||
"product_placeholder": "Select material type",
|
||||
"quantity": "Quantity",
|
||||
"quantity_label": "Quantity",
|
||||
"quantity_placeholder": "Amount",
|
||||
"destination": "Destination",
|
||||
"destination_placeholder": "City or hub",
|
||||
"location_label": "Delivery to",
|
||||
"location_placeholder": "City or region",
|
||||
"search_button": "Search",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
{
|
||||
"cabinetNav": {
|
||||
"catalog": "Каталог",
|
||||
"orders": "Мои заказы",
|
||||
"addresses": "Мои адреса",
|
||||
"billing": "Баланс",
|
||||
"profile": "Профиль",
|
||||
"team": "Компания",
|
||||
"teamSettings": "Настройки команды",
|
||||
"offers": "Мои предложения",
|
||||
"myOffers": "Мои предложения",
|
||||
"seller": "Продавец",
|
||||
"suppliers": "Поставщики",
|
||||
"hubs": "Хабы",
|
||||
"ai": "AI ассистент"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
"error": "Ошибка",
|
||||
"success": "Успех",
|
||||
"retry": "Повторить",
|
||||
"back": "Назад",
|
||||
"list": "Список",
|
||||
"map": "Карта",
|
||||
"language": "Язык",
|
||||
"theme": "Тема",
|
||||
"teams": "Команды",
|
||||
"selectTeam": "Выбрать команду",
|
||||
"actions": {
|
||||
"load_more": "Показать еще"
|
||||
},
|
||||
@@ -19,5 +26,9 @@
|
||||
"values": {
|
||||
"not_available": "Нет данных"
|
||||
}
|
||||
},
|
||||
"units": {
|
||||
"t": "т",
|
||||
"kg": "кг"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
"title": "Найти сырье",
|
||||
"subtitle": "и промышленные сервисы",
|
||||
"description": "Связывайтесь с поставщиками, получайте конкурентные цены и упрощайте процессы закупок",
|
||||
"product": "Товар",
|
||||
"product_label": "Что вы ищете?",
|
||||
"product_placeholder": "Выберите тип материала",
|
||||
"quantity": "Количество",
|
||||
"quantity_label": "Количество",
|
||||
"quantity_placeholder": "Объем",
|
||||
"destination": "Куда",
|
||||
"destination_placeholder": "Город или хаб",
|
||||
"location_label": "Доставка в",
|
||||
"location_placeholder": "Город или регион",
|
||||
"search_button": "Найти",
|
||||
|
||||
Reference in New Issue
Block a user