800 lines
28 KiB
Vue
800 lines
28 KiB
Vue
<script setup lang="ts">
|
|
const props = withDefaults(defineProps<{
|
|
isAssistantOpen?: boolean
|
|
profileLabel?: string
|
|
isAuthenticated?: boolean
|
|
showLogistics?: boolean
|
|
hideSearchCapsule?: boolean
|
|
}>(), {
|
|
isAssistantOpen: false,
|
|
profileLabel: '',
|
|
isAuthenticated: false,
|
|
showLogistics: false,
|
|
hideSearchCapsule: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:isAssistantOpen': [value: boolean]
|
|
}>()
|
|
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
const auth = useAuth()
|
|
const { basePath, isBasePathActive, navigateToLocalized, toLocalized } = useLocalizedNavigation()
|
|
const { draft: calcDraft, hydrateFromQuery, buildQuery: buildCalcQuery } = useCalcSearchDraft()
|
|
const isLandingPage = computed(() => basePath.value === '/')
|
|
const isAuthPage = computed(() =>
|
|
basePath.value === '/auth'
|
|
|| basePath.value === '/sign-in'
|
|
|| basePath.value === '/sign-out'
|
|
|| basePath.value === '/callback'
|
|
)
|
|
const isCalcPage = computed(() => basePath.value.startsWith('/catalog'))
|
|
const isManagerStagePage = computed(() => basePath.value.startsWith('/manager'))
|
|
const isDarkHeaderScene = computed(() => isLandingPage.value || isManagerStagePage.value)
|
|
const isAuthenticated = computed(() => props.isAuthenticated || auth.isAuthenticated.value)
|
|
const profileLabel = computed(() => props.profileLabel || (auth.user.value?.id ?? t('ui.profile')))
|
|
const showLogistics = computed(() => props.showLogistics || Boolean((auth.user.value as any)?.isAdmin))
|
|
const landingSearchScrollY = ref(0)
|
|
const landingSearchTopStart = ref(450)
|
|
const landingSearchTopStop = ref(16)
|
|
const LANDING_SEARCH_TOP_FALLBACK_START = 450
|
|
const LANDING_SEARCH_TOP_STOP = 16
|
|
const LANDING_SEARCH_BOTTOM_GAP = 30
|
|
|
|
const logisticsSearch = reactive({
|
|
destination: '',
|
|
product: '',
|
|
quantity: '',
|
|
})
|
|
|
|
function syncSearchFromRoute() {
|
|
hydrateFromQuery(route.query)
|
|
logisticsSearch.destination = typeof route.query.hubName === 'string'
|
|
? route.query.hubName
|
|
: typeof route.query.to === 'string'
|
|
? route.query.to
|
|
: isCalcPage.value
|
|
? calcDraft.value.to
|
|
: ''
|
|
logisticsSearch.product = typeof route.query.productName === 'string'
|
|
? route.query.productName
|
|
: typeof route.query.from === 'string'
|
|
? route.query.from
|
|
: isCalcPage.value
|
|
? calcDraft.value.from
|
|
: ''
|
|
logisticsSearch.quantity = typeof route.query.qty === 'string'
|
|
? route.query.qty
|
|
: typeof route.query.quantity === 'string'
|
|
? route.query.quantity
|
|
: typeof route.query.cargo === 'string'
|
|
? route.query.cargo
|
|
: isCalcPage.value
|
|
? calcDraft.value.cargo
|
|
: ''
|
|
}
|
|
|
|
function inferCountryIso(value: string, fallback: string) {
|
|
const normalized = value.trim().toLowerCase()
|
|
if (!normalized) return fallback
|
|
if (/(china|китай|guangzhou|shenzhen|yiwu|ningbo|beijing|shanghai|гуанчжоу|шанхай)/.test(normalized)) return 'CN'
|
|
if (/(russia|россия|moscow|москва|kazan|казань|saint petersburg|novosibirsk|екатеринбург)/.test(normalized)) return 'RU'
|
|
return fallback
|
|
}
|
|
|
|
function isoToFlag(iso: string) {
|
|
const normalized = iso.trim().toUpperCase()
|
|
if (!/^[A-Z]{2}$/.test(normalized)) return '🏳️'
|
|
return String.fromCodePoint(...[...normalized].map(char => 127397 + char.charCodeAt(0)))
|
|
}
|
|
|
|
const destinationIso = computed(() => inferCountryIso(logisticsSearch.destination, 'RU'))
|
|
const destinationFlag = computed(() => isoToFlag(destinationIso.value))
|
|
const showAdminDock = computed(() => Boolean(showLogistics.value) && !isAuthPage.value)
|
|
// Fullscreen menu
|
|
const isMenuOpen = ref(false)
|
|
const menuLinks = computed(() => {
|
|
const links: Array<{ label: string; to: string; icon: string }> = [
|
|
{ label: 'Optovia', to: '/', icon: 'map' },
|
|
{ label: t('ui.calculate'), to: '/catalog', icon: 'map' },
|
|
]
|
|
|
|
if (isAuthenticated.value) {
|
|
links.push({ label: t('ui.profile'), to: '/clientarea/profile', icon: 'referral' })
|
|
}
|
|
|
|
return links
|
|
})
|
|
|
|
watch(() => route.fullPath, () => {
|
|
isMenuOpen.value = false
|
|
syncSearchFromRoute()
|
|
nextTick(() => {
|
|
syncLandingSearchScroll()
|
|
syncLandingSearchAnchor()
|
|
})
|
|
})
|
|
|
|
function menuIconBg(icon: string) {
|
|
const map: Record<string, string> = {
|
|
orders: 'from-amber-400 to-orange-500',
|
|
apps: 'from-pink-500 to-rose-600',
|
|
logistics: 'from-cyan-500 to-blue-600',
|
|
map: 'from-emerald-400 to-teal-600',
|
|
consultation: 'from-sky-400 to-blue-600',
|
|
referral: 'from-fuchsia-500 to-pink-600',
|
|
}
|
|
return map[icon] || 'from-gray-400 to-gray-600'
|
|
}
|
|
|
|
onMounted(() => {
|
|
syncSearchFromRoute()
|
|
syncLandingSearchScroll()
|
|
syncLandingSearchAnchor()
|
|
if (import.meta.client) {
|
|
window.addEventListener('scroll', syncLandingSearchScroll, { passive: true })
|
|
window.addEventListener('resize', syncLandingSearchAnchor)
|
|
requestAnimationFrame(syncLandingSearchAnchor)
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
if (import.meta.client) {
|
|
window.removeEventListener('scroll', syncLandingSearchScroll)
|
|
window.removeEventListener('resize', syncLandingSearchAnchor)
|
|
}
|
|
})
|
|
|
|
function syncLandingSearchScroll() {
|
|
if (!import.meta.client) return
|
|
landingSearchScrollY.value = Math.max(0, window.scrollY || 0)
|
|
}
|
|
|
|
function syncLandingSearchAnchor() {
|
|
if (!import.meta.client) return
|
|
const headerShell = document.querySelector<HTMLElement>('[data-header-shell]')
|
|
const headerPill = document.querySelector<HTMLElement>('[data-header-pill]')
|
|
|
|
if (headerShell && headerPill) {
|
|
landingSearchTopStop.value = Math.max(
|
|
0,
|
|
Math.round(headerPill.getBoundingClientRect().top - headerShell.getBoundingClientRect().top),
|
|
)
|
|
}
|
|
else {
|
|
landingSearchTopStop.value = LANDING_SEARCH_TOP_STOP
|
|
}
|
|
|
|
if (!isLandingPage.value) {
|
|
landingSearchTopStart.value = LANDING_SEARCH_TOP_FALLBACK_START
|
|
return
|
|
}
|
|
|
|
const anchor = document.querySelector<HTMLElement>('[data-landing-search-anchor]')
|
|
if (!anchor) {
|
|
landingSearchTopStart.value = LANDING_SEARCH_TOP_FALLBACK_START
|
|
return
|
|
}
|
|
|
|
const anchorBottom = anchor.getBoundingClientRect().bottom + window.scrollY
|
|
landingSearchTopStart.value = Math.max(
|
|
landingSearchTopStop.value,
|
|
Math.round(anchorBottom + LANDING_SEARCH_BOTTOM_GAP),
|
|
)
|
|
}
|
|
|
|
const landingSearchTop = computed(() => {
|
|
if (!isLandingPage.value) return landingSearchTopStop.value
|
|
return Math.max(
|
|
landingSearchTopStop.value,
|
|
landingSearchTopStart.value - landingSearchScrollY.value,
|
|
)
|
|
})
|
|
|
|
const searchCapsuleWidthClass = 'w-full max-w-[1120px]'
|
|
const searchCapsuleBaseClass = `pill-glass h-16 min-w-0 rounded-full px-2.5 py-2.5 ${searchCapsuleWidthClass}`
|
|
|
|
const searchCapsuleClass = computed(() => {
|
|
if (isLandingPage.value) {
|
|
return `${searchCapsuleBaseClass} pointer-events-auto shadow-2xl`
|
|
}
|
|
return `${searchCapsuleBaseClass} pointer-events-auto`
|
|
})
|
|
|
|
const searchCapsuleWrapperClass = computed(() => {
|
|
if (isLandingPage.value) {
|
|
return 'pointer-events-none absolute inset-x-0 z-10 hidden justify-center lg:flex'
|
|
}
|
|
|
|
return 'pointer-events-none absolute inset-x-0 top-1/2 z-10 hidden -translate-y-1/2 justify-center lg:flex'
|
|
})
|
|
|
|
const searchCapsuleWrapperStyle = computed(() => {
|
|
if (!isLandingPage.value) return undefined
|
|
|
|
return {
|
|
top: `${landingSearchTop.value}px`,
|
|
}
|
|
})
|
|
|
|
const headerBackdropClass = computed(() => {
|
|
if (isManagerStagePage.value) {
|
|
return 'header-glass-backdrop--manager'
|
|
}
|
|
|
|
if (isLandingPage.value) {
|
|
return 'header-glass-backdrop--landing'
|
|
}
|
|
|
|
return 'header-glass-backdrop--default'
|
|
})
|
|
|
|
function buildHeaderSearchQuery(destination: string, product: string, quantity: string) {
|
|
const currentQuery = route.query || {}
|
|
const patch = {
|
|
from: product,
|
|
to: destination,
|
|
cargo: quantity,
|
|
}
|
|
const semanticQuery = {
|
|
...(product ? { productName: product } : {}),
|
|
...(destination ? { hubName: destination } : {}),
|
|
...(quantity ? { qty: quantity, quantity } : {}),
|
|
}
|
|
|
|
if (isCalcPage.value) {
|
|
return {
|
|
...currentQuery,
|
|
...buildCalcQuery(patch),
|
|
...semanticQuery,
|
|
}
|
|
}
|
|
|
|
return {
|
|
...currentQuery,
|
|
...(product ? { from: product } : {}),
|
|
...(destination ? { to: destination } : {}),
|
|
...(quantity ? { cargo: quantity } : {}),
|
|
...semanticQuery,
|
|
}
|
|
}
|
|
|
|
async function submitHeaderSearch() {
|
|
const destination = logisticsSearch.destination.trim()
|
|
const product = logisticsSearch.product.trim()
|
|
const quantity = logisticsSearch.quantity.trim()
|
|
await navigateToLocalized({
|
|
path: '/catalog',
|
|
query: buildHeaderSearchQuery(destination, product, quantity),
|
|
})
|
|
}
|
|
|
|
type StepRoute = 'destination' | 'product' | 'quantity'
|
|
|
|
async function openStep(step: StepRoute) {
|
|
const destination = logisticsSearch.destination.trim()
|
|
const product = logisticsSearch.product.trim()
|
|
const quantity = logisticsSearch.quantity.trim()
|
|
const stepPath = step === 'destination'
|
|
? '/catalog/destination'
|
|
: step === 'product'
|
|
? '/catalog/product'
|
|
: '/catalog/quantity'
|
|
await navigateToLocalized({
|
|
path: stepPath,
|
|
query: buildHeaderSearchQuery(destination, product, quantity),
|
|
})
|
|
}
|
|
|
|
async function goToSignIn() {
|
|
await auth.signIn()
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<header
|
|
class="header-glass fixed inset-x-0 top-0 z-50 border-0"
|
|
>
|
|
<div class="header-glass-backdrop" :class="headerBackdropClass" aria-hidden="true" />
|
|
<div class="relative z-10 mx-auto max-w-[2200px] px-4 py-4" data-header-shell>
|
|
<div class="grid items-center gap-2 lg:grid-cols-[auto_1fr_auto]">
|
|
<!-- Left pill: GL + hamburger (desktop only) -->
|
|
<div class="pill-glass relative z-20 hidden h-16 items-center rounded-full px-2.5 py-2.5 lg:flex" data-header-pill>
|
|
<NuxtLink :to="toLocalized('/')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">Optovia</NuxtLink>
|
|
<div class="h-6 w-px shrink-0 bg-base-300/85" />
|
|
<AppLocaleCurrencySelector />
|
|
<div class="h-6 w-px shrink-0 bg-base-300/85" />
|
|
<button
|
|
class="btn h-11 w-11 min-h-0 min-w-0 rounded-full border-0 bg-transparent p-0 shadow-none text-base-content/70"
|
|
:aria-label="$t('ui.menu')"
|
|
@click="isMenuOpen = !isMenuOpen"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="hidden lg:block" aria-hidden="true" />
|
|
|
|
<div v-if="!props.hideSearchCapsule" class="lg:hidden">
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary h-10 min-h-0 w-full rounded-full text-sm font-semibold"
|
|
@click="openStep('destination')"
|
|
>
|
|
{{ $t('ui.calculate') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Right pill: profile (desktop only) -->
|
|
<div class="pill-glass relative z-20 hidden h-16 items-center rounded-full px-2.5 py-2.5 lg:flex">
|
|
<NuxtLink :to="toLocalized('/clientarea/orders')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">
|
|
{{ $t('ui.my_orders') }}
|
|
</NuxtLink>
|
|
<div class="h-6 w-px shrink-0 bg-base-300/85" />
|
|
<NuxtLink v-if="isAuthenticated" :to="toLocalized('/clientarea/profile')" class="btn h-11 min-h-0 gap-2 rounded-full border-0 bg-transparent pl-2 pr-4 shadow-none">
|
|
<UserAvatar :seed="profileLabel" :label="profileLabel" :size="36" />
|
|
{{ profileLabel }}
|
|
</NuxtLink>
|
|
<button v-else class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none" @click="goToSignIn">{{ $t('ui.log_in') }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!props.hideSearchCapsule" :class="searchCapsuleWrapperClass" :style="searchCapsuleWrapperStyle">
|
|
<div :class="searchCapsuleClass">
|
|
<form class="flex min-w-0 flex-wrap items-center gap-2 rounded-full" @submit.prevent="submitHeaderSearch">
|
|
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
|
|
<span class="shrink-0 text-base leading-none">{{ destinationFlag }}</span>
|
|
<input
|
|
:value="logisticsSearch.destination"
|
|
type="text"
|
|
class="w-full cursor-pointer"
|
|
:placeholder="$t('ui.to')"
|
|
readonly
|
|
@focus.prevent="openStep('destination')"
|
|
@click.prevent="openStep('destination')"
|
|
/>
|
|
</label>
|
|
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
|
<path d="m3.3 7 8.7 5 8.7-5" />
|
|
<path d="M12 22V12" />
|
|
</svg>
|
|
<input
|
|
:value="logisticsSearch.product"
|
|
type="text"
|
|
class="w-full cursor-pointer"
|
|
:placeholder="$t('ui.product')"
|
|
readonly
|
|
@focus.prevent="openStep('product')"
|
|
@click.prevent="openStep('product')"
|
|
/>
|
|
</label>
|
|
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M10 13a2 2 0 1 1 0-4h4a2 2 0 1 1 0 4h-4Zm0 0v2m4-2v2" />
|
|
<path d="M6 5h12M6 19h12" />
|
|
</svg>
|
|
<input
|
|
:value="logisticsSearch.quantity"
|
|
type="text"
|
|
class="w-full cursor-pointer"
|
|
:placeholder="$t('ui.quantity')"
|
|
readonly
|
|
@focus.prevent="openStep('quantity')"
|
|
@click.prevent="openStep('quantity')"
|
|
/>
|
|
</label>
|
|
<button type="submit" class="btn btn-secondary h-11 min-h-0 rounded-full px-5">{{ $t('ui.find') }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<nav v-if="showAdminDock" class="admin-dock-shell" :aria-label="$t('ui.manager_navigation')">
|
|
<div class="admin-dock-glass">
|
|
<NuxtLink
|
|
:to="toLocalized('/manager/orders')"
|
|
class="admin-dock-item"
|
|
:class="isBasePathActive('/manager/orders') ? 'admin-dock-item--active' : ''"
|
|
:aria-label="$t('ui.orders')"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 7h11v10H3z" />
|
|
<path d="M14 10h4l3 3v4h-7z" />
|
|
<circle cx="7.5" cy="18" r="1.5" />
|
|
<circle cx="18.5" cy="18" r="1.5" />
|
|
</svg>
|
|
<span class="admin-dock-label">{{ $t('ui.orders') }}</span>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink
|
|
:to="toLocalized('/manager/quotations')"
|
|
class="admin-dock-item"
|
|
:class="isBasePathActive('/manager/quotations') || isBasePathActive('/manager/tariffs') ? 'admin-dock-item--active' : ''"
|
|
:aria-label="$t('ui.quotations')"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M7 3h8l4 4v14H5V3z" />
|
|
<path d="M15 3v5h5" />
|
|
<path d="M8 12h8" />
|
|
<path d="M8 16h8" />
|
|
</svg>
|
|
<span class="admin-dock-label">{{ $t('ui.quotations') }}</span>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink
|
|
:to="toLocalized('/manager')"
|
|
class="admin-dock-item"
|
|
:class="isBasePathActive('/manager') ? 'admin-dock-item--active' : ''"
|
|
:aria-label="$t('ui.hubs')"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="6" cy="17" r="2" />
|
|
<circle cx="18" cy="7" r="2" />
|
|
<circle cx="18" cy="17" r="2" />
|
|
<path d="M7.5 15.5 16.5 8.5" />
|
|
<path d="M8 17h8" />
|
|
</svg>
|
|
<span class="admin-dock-label">{{ $t('ui.hubs') }}</span>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink
|
|
:to="toLocalized('/manager')"
|
|
class="admin-dock-item"
|
|
:class="isBasePathActive('/manager') ? 'admin-dock-item--active' : ''"
|
|
:aria-label="$t('ui.users')"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9.5" cy="7" r="3" />
|
|
<path d="M21 21v-2a4 4 0 0 0-3-3.87" />
|
|
<path d="M16 3.13a3 3 0 0 1 0 5.74" />
|
|
</svg>
|
|
<span class="admin-dock-label">{{ $t('ui.users') }}</span>
|
|
</NuxtLink>
|
|
</div>
|
|
</nav>
|
|
|
|
<!-- Mobile dock bar (bottom) -->
|
|
<nav v-else-if="!isAuthPage" class="dock-glass fixed inset-x-0 bottom-0 z-50 flex items-center justify-around px-2 py-2 lg:hidden">
|
|
<NuxtLink
|
|
:to="toLocalized('/')"
|
|
class="dock-item"
|
|
:class="isBasePathActive('/', true) ? 'dock-item--active' : ''"
|
|
aria-label="Optovia"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
<polyline points="9 22 9 12 15 12 15 22" />
|
|
</svg>
|
|
<span class="dock-label">Optovia</span>
|
|
</NuxtLink>
|
|
|
|
<button
|
|
class="dock-item"
|
|
:class="isMenuOpen ? 'dock-item--active' : ''"
|
|
:aria-label="$t('ui.menu')"
|
|
@click="isMenuOpen = !isMenuOpen"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
<rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" />
|
|
</svg>
|
|
<span class="dock-label">{{ $t('ui.menu') }}</span>
|
|
</button>
|
|
|
|
<NuxtLink
|
|
v-if="showLogistics"
|
|
:to="toLocalized('/manager')"
|
|
class="dock-item"
|
|
:class="isBasePathActive('/manager') ? 'dock-item--active' : ''"
|
|
:aria-label="$t('ui.manager')"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 7h11v10H3z" />
|
|
<path d="M14 10h4l3 3v4h-7z" />
|
|
<circle cx="7.5" cy="18" r="1.5" />
|
|
<circle cx="18.5" cy="18" r="1.5" />
|
|
</svg>
|
|
<span class="dock-label">{{ $t('ui.manager') }}</span>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink
|
|
:to="toLocalized('/clientarea/orders')"
|
|
class="dock-item"
|
|
:class="isBasePathActive('/clientarea/orders') ? 'dock-item--active' : ''"
|
|
:aria-label="$t('ui.orders')"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 7h11v10H3z" />
|
|
<path d="M14 10h4l3 3v4h-7z" />
|
|
<circle cx="7.5" cy="18" r="1.5" />
|
|
<circle cx="18.5" cy="18" r="1.5" />
|
|
</svg>
|
|
<span class="dock-label">{{ $t('ui.orders') }}</span>
|
|
</NuxtLink>
|
|
|
|
<NuxtLink
|
|
v-if="isAuthenticated"
|
|
:to="toLocalized('/clientarea/profile')"
|
|
class="dock-item"
|
|
:class="isBasePathActive('/clientarea/profile') || isBasePathActive('/clientarea/team') ? 'dock-item--active' : ''"
|
|
:aria-label="$t('ui.profile')"
|
|
>
|
|
<UserAvatar :seed="profileLabel" :label="profileLabel" :size="24" />
|
|
<span class="dock-label">{{ $t('ui.profile') }}</span>
|
|
</NuxtLink>
|
|
<button
|
|
v-else
|
|
class="dock-item"
|
|
:aria-label="$t('ui.login')"
|
|
@click="goToSignIn"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
<span class="dock-label">{{ $t('ui.log_in') }}</span>
|
|
</button>
|
|
</nav>
|
|
|
|
<!-- Fullscreen nav menu -->
|
|
<Teleport to="body">
|
|
<Transition name="menu-overlay">
|
|
<div
|
|
v-if="isMenuOpen"
|
|
class="fixed inset-0 z-[100] flex flex-col overflow-y-auto bg-[#0b0b0d]"
|
|
>
|
|
<div class="flex justify-end px-5 pt-5">
|
|
<button
|
|
class="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
|
|
@click="isMenuOpen = false"
|
|
>
|
|
<svg viewBox="0 0 24 24" fill="none" class="h-6 w-6" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
<path d="M18 6 6 18M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<nav class="mx-auto flex w-full max-w-md flex-1 flex-col justify-center gap-0 px-6 py-8">
|
|
<NuxtLink
|
|
v-for="(link, i) in menuLinks"
|
|
:key="link.to"
|
|
:to="toLocalized(link.to)"
|
|
class="menu-item group flex items-center gap-4 rounded-2xl px-5 py-5 transition-all duration-200 hover:bg-white/8 hover:pl-7"
|
|
:class="isBasePathActive(link.to) ? 'bg-white/8' : ''"
|
|
:style="{ animationDelay: `${i * 60}ms` }"
|
|
@click="isMenuOpen = false"
|
|
>
|
|
<div
|
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br shadow-lg transition-transform duration-200 group-hover:scale-110"
|
|
:class="menuIconBg(link.icon)"
|
|
>
|
|
<svg v-if="link.icon === 'orders'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" />
|
|
</svg>
|
|
<svg v-else-if="link.icon === 'logistics'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M3 7h11v10H3z" />
|
|
<path d="M14 10h4l3 3v4h-7z" />
|
|
<circle cx="7.5" cy="18" r="1.5" />
|
|
<circle cx="18.5" cy="18" r="1.5" />
|
|
</svg>
|
|
<svg v-else-if="link.icon === 'apps'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="13.5" cy="6.5" r="2" /><circle cx="17.5" cy="10.5" r="2" /><circle cx="8.5" cy="7.5" r="2" /><circle cx="6.5" cy="12" r="2" />
|
|
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.5-.7 1.5-1.5 0-.4-.1-.7-.4-1-.3-.3-.4-.7-.4-1.1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-5.5-4.5-9.9-10-9.9Z" />
|
|
</svg>
|
|
<svg v-else-if="link.icon === 'map'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" /><circle cx="12" cy="10" r="3" />
|
|
</svg>
|
|
<svg v-else-if="link.icon === 'referral'" viewBox="0 0 24 24" fill="none" class="h-5 w-5 text-white" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
|
</svg>
|
|
</div>
|
|
<span class="text-lg font-bold text-white md:text-xl">{{ link.label }}</span>
|
|
<svg viewBox="0 0 24 24" fill="none" class="ml-auto h-5 w-5 shrink-0 text-white/0 transition-all duration-200 group-hover:translate-x-1 group-hover:text-white/50" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
</NuxtLink>
|
|
</nav>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style>
|
|
.header-glass {
|
|
background: transparent;
|
|
border: 0;
|
|
}
|
|
|
|
.header-glass-backdrop {
|
|
position: absolute;
|
|
inset: 0;
|
|
height: 220%;
|
|
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
|
backdrop-filter: blur(16px) saturate(180%);
|
|
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 28%, rgba(0,0,0,0.35) 52%, rgba(0,0,0,0.08) 74%, transparent 100%);
|
|
mask-image: linear-gradient(to bottom, black 0%, black 28%, rgba(0,0,0,0.35) 52%, rgba(0,0,0,0.08) 74%, transparent 100%);
|
|
pointer-events: none;
|
|
z-index: 0;
|
|
}
|
|
|
|
.header-glass-backdrop--default {
|
|
background: linear-gradient(180deg, rgba(243, 238, 230, 0.92) 0%, rgba(243, 238, 230, 0.72) 38%, rgba(243, 238, 230, 0) 100%);
|
|
}
|
|
|
|
.header-glass-backdrop--landing {
|
|
background: linear-gradient(180deg, rgba(11, 26, 47, 0.78) 0%, rgba(11, 26, 47, 0.38) 36%, rgba(11, 26, 47, 0) 100%);
|
|
}
|
|
|
|
.header-glass-backdrop--manager {
|
|
background: linear-gradient(180deg, rgba(8, 17, 29, 0.84) 0%, rgba(8, 17, 29, 0.48) 36%, rgba(8, 17, 29, 0) 100%);
|
|
}
|
|
|
|
.pill-glass {
|
|
position: relative;
|
|
background: rgba(243, 238, 230, 0.94);
|
|
border: 0;
|
|
box-shadow: 0 14px 34px rgba(62, 47, 26, 0.12);
|
|
}
|
|
|
|
.pill-glass::after {
|
|
display: none;
|
|
}
|
|
|
|
.search-arch {
|
|
background: #ffffff;
|
|
border: 0;
|
|
box-shadow: none;
|
|
padding-left: 0.7rem;
|
|
padding-right: 0.7rem;
|
|
}
|
|
|
|
.search-arch span {
|
|
color: rgba(55, 65, 81, 0.72);
|
|
}
|
|
|
|
.search-arch input::placeholder {
|
|
color: rgba(107, 114, 128, 0.62);
|
|
}
|
|
|
|
.nav-pill {
|
|
transition: left 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
|
width 0.35s cubic-bezier(0.22, 1, 0.36, 1),
|
|
opacity 0.2s ease;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.menu-overlay-enter-active {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
.menu-overlay-enter-active .menu-item {
|
|
animation: menu-item-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
}
|
|
.menu-overlay-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
.menu-overlay-leave-active .menu-item {
|
|
animation: menu-item-out 0.2s ease both;
|
|
}
|
|
.menu-overlay-enter-from,
|
|
.menu-overlay-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
@keyframes menu-item-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateX(-30px) scale(0.95);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateX(0) scale(1);
|
|
}
|
|
}
|
|
|
|
@keyframes menu-item-out {
|
|
to {
|
|
opacity: 0;
|
|
transform: translateX(20px);
|
|
}
|
|
}
|
|
|
|
.dock-glass {
|
|
background: #e8ddca;
|
|
border-top: 1px solid #d5c7b0;
|
|
box-shadow: 0 -4px 14px rgba(60, 47, 29, 0.12);
|
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
}
|
|
|
|
.admin-dock-shell {
|
|
position: fixed;
|
|
inset-inline: 0;
|
|
bottom: 0;
|
|
z-index: 50;
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 0 1rem calc(env(safe-area-inset-bottom, 0px) + 1rem);
|
|
pointer-events: none;
|
|
}
|
|
|
|
.admin-dock-glass {
|
|
pointer-events: auto;
|
|
display: inline-flex;
|
|
height: 4rem;
|
|
width: fit-content;
|
|
max-width: min(100%, calc(100vw - 2rem));
|
|
gap: 0.3rem;
|
|
border-radius: 999px;
|
|
border: 1px solid rgba(219, 208, 191, 0.78);
|
|
background: rgba(243, 238, 230, 0.9);
|
|
-webkit-backdrop-filter: blur(18px) saturate(180%);
|
|
backdrop-filter: blur(18px) saturate(180%);
|
|
box-shadow:
|
|
0 20px 44px rgba(52, 40, 24, 0.2),
|
|
0 6px 16px rgba(52, 40, 24, 0.1);
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
.admin-dock-item {
|
|
display: flex;
|
|
height: 3rem;
|
|
min-width: 0;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.45rem;
|
|
border-radius: 999px;
|
|
padding: 0 0.9rem;
|
|
color: rgba(52, 40, 24, 0.72);
|
|
transition:
|
|
background-color 0.16s ease,
|
|
color 0.16s ease,
|
|
transform 0.16s ease;
|
|
}
|
|
|
|
.admin-dock-item:hover {
|
|
background: rgba(255, 255, 255, 0.64);
|
|
color: rgba(33, 25, 15, 0.88);
|
|
}
|
|
|
|
.admin-dock-item--active {
|
|
background: #2f2416;
|
|
color: #fff8ef;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.admin-dock-label {
|
|
font-size: 0.78rem;
|
|
font-weight: 800;
|
|
line-height: 1;
|
|
letter-spacing: 0.02em;
|
|
text-align: center;
|
|
}
|
|
|
|
.dock-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 2px;
|
|
padding: 6px 12px;
|
|
border-radius: 12px;
|
|
color: rgba(30, 23, 14, 0.65);
|
|
transition: color 0.15s ease;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.dock-item--active {
|
|
color: oklch(var(--s));
|
|
}
|
|
|
|
.dock-label {
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
line-height: 1;
|
|
}
|
|
</style>
|