feat(ui): align web shell and landing with logistics style

This commit is contained in:
Ruslan Bakiev
2026-04-21 10:26:55 +07:00
parent a74e75049c
commit 670e9b7fd1
5 changed files with 1142 additions and 979 deletions

View File

@@ -1,155 +1,16 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "cupcake";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97.788% 0.004 56.375);
--color-base-200: oklch(93.982% 0.007 61.449);
--color-base-300: oklch(91.586% 0.006 53.44);
--color-base-content: oklch(23.574% 0.066 313.189);
--color-primary: oklch(85% 0.138 181.071);
--color-primary-content: oklch(43% 0.078 188.216);
--color-secondary: oklch(89% 0.061 343.231);
--color-secondary-content: oklch(45% 0.187 3.815);
--color-accent: oklch(90% 0.076 70.697);
--color-accent-content: oklch(47% 0.157 37.304);
--color-neutral: oklch(27% 0.006 286.033);
--color-neutral-content: oklch(92% 0.004 286.32);
--color-info: oklch(68% 0.169 237.323);
--color-info-content: oklch(29% 0.066 243.157);
--color-success: oklch(69% 0.17 162.48);
--color-success-content: oklch(26% 0.051 172.552);
--color-warning: oklch(79% 0.184 86.047);
--color-warning-content: oklch(28% 0.066 53.813);
--color-error: oklch(64% 0.246 16.439);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 2rem;
--radius-field: 2rem;
--radius-box: 2rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 0.5px;
--depth: 1;
--noise: 0;
}
@layer components {
/* ── Three-tier glass system (Apple-style glassmorphism) ── */
/* Tier 1 — lightest underlay, large panels / sidebars */
.glass-underlay {
background: rgba(255, 255, 255, 0.34);
box-shadow:
0 16px 44px rgba(24, 20, 12, 0.11),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
/* Tier 2 — medium capsule, nav pills / search bar */
.glass-capsule {
background: rgba(255, 255, 255, 0.56);
box-shadow:
0 8px 24px rgba(24, 20, 12, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.56);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
/* Tier 3 — densest chip, small tags / badges */
.glass-chip {
background: rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.62);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
/* Legacy aliases — keep backward compat during transition */
.glass-soft {
background: rgba(255, 255, 255, 0.34);
box-shadow:
0 16px 44px rgba(24, 20, 12, 0.11),
inset 0 1px 0 rgba(255, 255, 255, 0.4);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.glass-bright {
background: rgba(255, 255, 255, 0.56);
box-shadow:
0 8px 24px rgba(24, 20, 12, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.56);
backdrop-filter: blur(22px);
-webkit-backdrop-filter: blur(22px);
}
/* Map overlays must stay solid (no glass) */
.map-chip {
background: color-mix(in oklab, var(--color-base-100) 96%, transparent);
border: 1px solid color-mix(in oklab, var(--color-base-300) 80%, transparent);
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
}
}
/* ── Header glass: two-layer Apple-style glassmorphism ── */
.header-glass {
background: transparent;
}
/* Layer 1: frosted bar backdrop — fades to transparent at bottom */
.header-glass-backdrop {
position: absolute;
inset: 0;
height: 350%;
background: rgba(255, 255, 255, 0.06);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(16px) saturate(180%);
-webkit-mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
mask-image: linear-gradient(to bottom, black 0%, black 20%, rgba(0,0,0,0.4) 40%, rgba(0,0,0,0.1) 65%, transparent 100%);
pointer-events: none;
z-index: 0;
}
/* Layer 2: capsule pills — denser frosted glass with inner shine */
.pill-glass {
position: relative;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.16);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
box-shadow:
0 8px 32px rgba(31, 38, 135, 0.2),
inset 0 4px 20px rgba(255, 255, 255, 0.3);
}
/* Inner shine highlight — liquid glass refraction */
.pill-glass::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: rgba(255, 255, 255, 0.1);
box-shadow:
inset -10px -8px 0 -11px rgba(255, 255, 255, 1),
inset 0 -9px 0 -8px rgba(255, 255, 255, 1);
opacity: 0.6;
filter: blur(1px) brightness(115%);
pointer-events: none;
}
@plugin "daisyui/theme" {
name: "silk";
default: false;
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(97% 0.0035 67.78);
--color-base-200: oklch(95% 0.0081 61.42);
--color-base-300: oklch(90% 0.0081 61.42);
--color-base-content: oklch(40% 0.0081 61.42);
--color-base-content: oklch(24% 0.02 256);
--color-primary: oklch(23.27% 0.0249 284.3);
--color-primary-content: oklch(94.22% 0.2505 117.44);
--color-secondary: oklch(23.27% 0.0249 284.3);
@@ -167,48 +28,60 @@
--color-error: oklch(75.1% 0.1814 22.37);
--color-error-content: oklch(35.1% 0.1814 22.37);
--radius-selector: 2rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.28125rem;
--size-field: 0.28125rem;
--radius-field: 2rem;
--radius-box: 2rem;
--size-selector: 0.3125rem;
--size-field: 0.3125rem;
--border: 0.5px;
--depth: 0;
--noise: 0;
}
@plugin "daisyui/theme" {
name: "night";
default: false;
prefersdark: true;
color-scheme: "dark";
--color-base-100: oklch(20.768% 0.039 265.754);
--color-base-200: oklch(19.314% 0.037 265.754);
--color-base-300: oklch(17.86% 0.034 265.754);
--color-base-content: oklch(84.153% 0.007 265.754);
--color-primary: oklch(75.351% 0.138 232.661);
--color-primary-content: oklch(15.07% 0.027 232.661);
--color-secondary: oklch(68.011% 0.158 276.934);
--color-secondary-content: oklch(13.602% 0.031 276.934);
--color-accent: oklch(72.36% 0.176 350.048);
--color-accent-content: oklch(14.472% 0.035 350.048);
--color-neutral: oklch(27.949% 0.036 260.03);
--color-neutral-content: oklch(85.589% 0.007 260.03);
--color-info: oklch(68.455% 0.148 237.251);
--color-info-content: oklch(0% 0 0);
--color-success: oklch(78.452% 0.132 181.911);
--color-success-content: oklch(15.69% 0.026 181.911);
--color-warning: oklch(83.242% 0.139 82.95);
--color-warning-content: oklch(16.648% 0.027 82.95);
--color-error: oklch(71.785% 0.17 13.118);
--color-error-content: oklch(14.357% 0.034 13.118);
--radius-selector: 1rem;
--radius-field: 1rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 0.5px;
--depth: 0;
--noise: 0;
:root {
color-scheme: light;
}
html {
scroll-behavior: smooth;
color-scheme: light;
background-color: #f7f5f1;
}
body {
min-height: 100vh;
color-scheme: light;
font-family: "Onest", "Avenir Next", "Trebuchet MS", sans-serif;
color: oklch(24% 0.02 256);
background:
radial-gradient(circle at 10% 0%, #fdf8ea 0%, rgba(253, 248, 234, 0) 40%),
radial-gradient(circle at 90% 100%, #e9f2ff 0%, rgba(233, 242, 255, 0) 35%),
linear-gradient(135deg, #f7f5f1 0%, #f1eee8 100%);
}
.glass-underlay,
.glass-soft {
background: #ece3d3;
border: 1px solid #d7ccb7;
box-shadow: 0 14px 30px rgba(24, 20, 12, 0.1);
}
.glass-capsule,
.glass-bright {
background: #e9decb;
border: 1px solid #d5c7b0;
box-shadow: 0 8px 22px rgba(24, 20, 12, 0.1);
}
.glass-chip {
background: #f2eadb;
border: 1px solid #dbcdb8;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
.map-chip {
background: #f2eadb;
border: 1px solid #dbcdb8;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12);
}
.manager-logistics-shell {

View File

@@ -0,0 +1,474 @@
<script setup lang="ts">
interface MenuLink {
label: string
to: string
icon: 'home' | 'catalog' | 'orders' | 'profile'
}
const props = withDefaults(defineProps<{
hideSearchCapsule?: boolean
}>(), {
hideSearchCapsule: false,
})
const route = useRoute()
const localePath = useLocalePath()
const switchLocalePath = useSwitchLocalePath()
const { locale, locales } = useI18n()
const { signIn, signOut, loggedIn, user } = useAuth()
const { productLabel, hubLabel, supplierLabel, quantity, canSearch } = useCatalogSearch()
const isMenuOpen = ref(false)
const isAuthenticated = computed(() => loggedIn.value || Boolean(user.value?.id))
const profileLabel = computed(() => {
return user.value?.id || (locale.value === 'en' ? 'Profile' : 'Профиль')
})
const isHomePage = computed(() => route.path === localePath('/'))
const isCatalogPage = computed(() => route.path.startsWith(localePath('/catalog')))
const isClientAreaPage = computed(() => route.path.startsWith(localePath('/clientarea')))
const isAuthPage = computed(() => {
return route.path === localePath('/auth')
|| route.path === localePath('/sign-in')
|| route.path === localePath('/callback')
})
const showSearchCapsule = computed(() => {
if (props.hideSearchCapsule || isAuthPage.value) return false
return isHomePage.value || isCatalogPage.value
})
const headerBackdropClass = computed(() => {
if (isHomePage.value) return 'header-glass-backdrop--landing'
return 'header-glass-backdrop--default'
})
const menuLinks = computed<MenuLink[]>(() => {
const links: MenuLink[] = [
{ label: locale.value === 'en' ? 'Home' : 'Главная', to: '/', icon: 'home' },
{ label: locale.value === 'en' ? 'Catalog' : 'Каталог', to: '/catalog', icon: 'catalog' },
{ label: locale.value === 'en' ? 'Orders' : 'Заказы', to: '/clientarea/orders', icon: 'orders' },
]
if (isAuthenticated.value) {
links.push({ label: locale.value === 'en' ? 'Profile' : 'Профиль', to: '/clientarea/profile', icon: 'profile' })
}
return links
})
const searchProductLabel = computed(() => productLabel.value || (locale.value === 'en' ? 'Product' : 'Товар'))
const searchDestinationLabel = computed(() => {
return hubLabel.value || supplierLabel.value || (locale.value === 'en' ? 'Destination' : 'Точка назначения')
})
const searchQuantityLabel = computed(() => {
return quantity.value ? `${quantity.value} t` : (locale.value === 'en' ? 'Volume' : 'Объем')
})
function isBasePathActive(path: string, exact = false) {
const localized = localePath(path)
if (exact) return route.path === localized
return route.path === localized || route.path.startsWith(`${localized}/`)
}
async function openStep(step: 'product' | 'destination' | 'quantity') {
await navigateTo(localePath(`/catalog/${step}`))
}
async function submitHeaderSearch() {
if (canSearch.value) {
await navigateTo(localePath('/catalog/results'))
return
}
await navigateTo(localePath('/catalog/product'))
}
async function handleAuthAction() {
if (isAuthenticated.value) {
await signOut()
return
}
await signIn()
}
watch(() => route.fullPath, () => {
isMenuOpen.value = false
})
</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">
<div class="grid items-center gap-2 lg:grid-cols-[auto_1fr_auto]">
<div class="pill-glass relative z-20 hidden h-16 items-center rounded-full px-2.5 py-2.5 lg:flex">
<NuxtLink :to="localePath('/')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none text-sm font-black tracking-wide">
optovia
</NuxtLink>
<div class="h-6 w-px shrink-0 bg-base-300/85" />
<div class="flex items-center rounded-full bg-white/55 p-1">
<NuxtLink
v-for="lang in locales"
:key="lang.code"
:to="switchLocalePath(lang.code)"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="locale === lang.code ? 'bg-white text-[#2f2418]' : 'text-[#6f6250] hover:text-[#2f2418]'"
>
{{ lang.code.toUpperCase() }}
</NuxtLink>
</div>
<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="locale === 'en' ? '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 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('product')"
>
{{ locale === 'en' ? 'Start Search' : 'Начать поиск' }}
</button>
</div>
<div class="pill-glass relative z-20 hidden h-16 items-center rounded-full px-2.5 py-2.5 lg:flex">
<NuxtLink :to="localePath('/catalog')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">
{{ locale === 'en' ? 'Catalog' : 'Каталог' }}
</NuxtLink>
<div class="h-6 w-px shrink-0 bg-base-300/85" />
<NuxtLink :to="localePath('/clientarea/orders')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">
{{ locale === 'en' ? 'Orders' : 'Заказы' }}
</NuxtLink>
<div class="h-6 w-px shrink-0 bg-base-300/85" />
<NuxtLink
v-if="isAuthenticated"
:to="localePath('/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="34" />
{{ profileLabel }}
</NuxtLink>
<button
v-else
class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none"
@click="handleAuthAction"
>
{{ locale === 'en' ? 'Log in' : 'Войти' }}
</button>
</div>
</div>
<div v-if="showSearchCapsule" class="pointer-events-none absolute inset-x-0 z-10 hidden justify-center lg:flex" style="top: 16px;">
<div class="pill-glass h-16 min-w-0 w-full max-w-[1120px] rounded-full px-2.5 py-2.5 pointer-events-auto shadow-2xl">
<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">🛒</span>
<input
:value="searchProductLabel"
type="text"
class="w-full cursor-pointer"
:placeholder="locale === 'en' ? '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">
<span class="shrink-0 text-base leading-none">📍</span>
<input
:value="searchDestinationLabel"
type="text"
class="w-full cursor-pointer"
:placeholder="locale === 'en' ? 'Destination' : 'Назначение'"
readonly
@focus.prevent="openStep('destination')"
@click.prevent="openStep('destination')"
>
</label>
<label class="search-arch input flex h-11 min-h-0 min-w-[190px] flex-[1.4] 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="searchQuantityLabel"
type="text"
class="w-full cursor-pointer"
:placeholder="locale === 'en' ? 'Volume' : 'Объем'"
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">
{{ locale === 'en' ? 'Find' : 'Найти' }}
</button>
</form>
</div>
</div>
</div>
</header>
<nav v-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="localePath('/')" class="dock-item" :class="isBasePathActive('/', true) ? 'dock-item--active' : ''" aria-label="Home">
<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">{{ locale === 'en' ? 'Home' : 'Домой' }}</span>
</NuxtLink>
<NuxtLink :to="localePath('/catalog')" class="dock-item" :class="isBasePathActive('/catalog') ? 'dock-item--active' : ''" aria-label="Catalog">
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="2" 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>
<span class="dock-label">{{ locale === 'en' ? 'Catalog' : 'Каталог' }}</span>
</NuxtLink>
<NuxtLink :to="localePath('/clientarea/orders')" class="dock-item" :class="isBasePathActive('/clientarea/orders') ? 'dock-item--active' : ''" aria-label="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">{{ locale === 'en' ? 'Orders' : 'Заказы' }}</span>
</NuxtLink>
<NuxtLink
v-if="isAuthenticated"
:to="localePath('/clientarea/profile')"
class="dock-item"
:class="isBasePathActive('/clientarea/profile') ? 'dock-item--active' : ''"
aria-label="Profile"
>
<UserAvatar :seed="profileLabel" :label="profileLabel" :size="24" />
<span class="dock-label">{{ locale === 'en' ? 'Profile' : 'Профиль' }}</span>
</NuxtLink>
<button v-else class="dock-item" aria-label="Auth" @click="handleAuthAction">
<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">{{ locale === 'en' ? 'Log in' : 'Войти' }}</span>
</button>
</nav>
<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="localePath(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 from-amber-400 to-orange-500 shadow-lg transition-transform duration-200 group-hover:scale-110">
<svg v-if="link.icon === 'home'" 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 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>
<svg v-else-if="link.icon === 'catalog'" 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 === '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">
<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 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 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div class="min-w-0">
<p class="text-xl font-semibold text-white">{{ link.label }}</p>
</div>
</NuxtLink>
<button
class="menu-item mt-6 flex items-center gap-3 rounded-2xl px-5 py-4 text-left text-white/80 transition hover:bg-white/8"
@click="handleAuthAction"
>
<svg viewBox="0 0 24 24" fill="none" class="h-5 w-5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
<span class="text-base font-medium">
{{ isAuthenticated ? (locale === 'en' ? 'Log out' : 'Выйти') : (locale === 'en' ? 'Log in' : 'Войти') }}
</span>
</button>
</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%);
}
.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);
}
.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);
}
.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: #2f2416;
}
.dock-label {
font-size: 10px;
font-weight: 600;
line-height: 1;
}
</style>

View File

@@ -35,17 +35,22 @@
<!-- List button (LEFT, opens panel) - hide when panel is open -->
<button
v-if="!isPanelOpen"
class="absolute top-[116px] left-4 z-20 hidden lg:flex items-center gap-2 map-chip rounded-full px-3 py-1.5 text-base-content text-sm hover:bg-base-200 transition-colors"
class="absolute bottom-4 left-4 top-[108px] z-20 hidden w-14 lg:flex flex-col items-center justify-between rounded-full border border-[#d5c7b0] bg-[#f3eee6] px-2 py-3 text-[#2f2418] shadow-[0_20px_40px_rgba(38,29,18,0.18)] transition-colors hover:bg-[#eee6dc]"
@click="openPanel"
>
<Icon name="lucide:menu" size="16" />
<span>{{ $t('catalog.list') }}</span>
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-white text-[#5f4b33]">
<Icon name="lucide:panel-left-open" size="16" />
</span>
<span class="-rotate-90 whitespace-nowrap text-[11px] font-bold uppercase tracking-[0.14em] text-[#6f6353]">
{{ $t('catalog.list') }}
</span>
<span class="h-10 w-10" aria-hidden="true" />
</button>
<!-- Filter by bounds checkbox (LEFT, next to panel when open) - only in selection mode -->
<label
v-if="selectMode !== null"
class="absolute top-[116px] z-20 hidden lg:flex items-center gap-2 map-chip rounded-full px-3 py-1.5 cursor-pointer text-base-content text-sm hover:bg-base-200 transition-colors"
class="absolute top-[108px] z-20 hidden lg:flex items-center gap-2 rounded-full border border-[#d5c7b0] bg-[#f3eee6] px-3 py-1.5 cursor-pointer text-[#2f2418] text-sm shadow-[0_12px_26px_rgba(38,29,18,0.12)] transition-colors hover:bg-[#eee6dc]"
:style="boundsFilterStyle"
>
<input
@@ -61,11 +66,11 @@
<!-- View toggle (top RIGHT overlay, below header) - hide in info mode or when hideViewToggle -->
<div v-if="!isInfoMode && !hideViewToggle" class="absolute top-[116px] right-4 z-20 hidden lg:flex items-center gap-2">
<!-- View mode toggle -->
<div class="flex gap-1 map-chip rounded-full p-1">
<div class="flex gap-1 rounded-full border border-[#d5c7b0] bg-[#f3eee6] p-1 shadow-[0_12px_26px_rgba(38,29,18,0.12)]">
<button
v-if="showOffersToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'offers' ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
:class="mapViewMode === 'offers' ? 'bg-[#2f2416] text-[#fff8ef]' : 'text-[#6b5b48] hover:text-[#2f2418] hover:bg-[#ece3d3]'"
@click="setMapViewMode('offers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
@@ -76,7 +81,7 @@
<button
v-if="showHubsToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
:class="mapViewMode === 'hubs' ? 'bg-[#2f2416] text-[#fff8ef]' : 'text-[#6b5b48] hover:text-[#2f2418] hover:bg-[#ece3d3]'"
@click="setMapViewMode('hubs')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
@@ -87,7 +92,7 @@
<button
v-if="showSuppliersToggle"
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-bold transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-base-300 text-base-content' : 'text-base-content/70 hover:text-base-content hover:bg-base-200'"
:class="mapViewMode === 'suppliers' ? 'bg-[#2f2416] text-[#fff8ef]' : 'text-[#6b5b48] hover:text-[#2f2418] hover:bg-[#ece3d3]'"
@click="setMapViewMode('suppliers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
@@ -102,10 +107,10 @@
<Transition name="slide-left">
<div
v-if="isPanelOpen"
class="absolute top-[116px] left-0 bottom-0 z-30 max-w-[calc(100vw-1rem)] hidden lg:block"
class="absolute top-[108px] left-4 bottom-4 z-30 max-w-[calc(100vw-1rem)] hidden lg:block"
:class="panelWidth"
>
<div class="bg-base-100 text-base-content border border-base-300 border-l-0 rounded-none rounded-tr-[1.1rem] rounded-bl-[1.1rem] shadow-2xl h-full flex flex-col">
<div class="h-full flex flex-col overflow-hidden rounded-[28px] border-0 bg-[#f3eee6] text-[#2f2418] shadow-[0_28px_70px_rgba(38,29,18,0.18)]">
<slot name="panel" />
</div>
</div>
@@ -117,7 +122,7 @@
<div class="flex justify-between px-4 mb-2">
<!-- List button (mobile) -->
<button
class="flex items-center gap-2 map-chip rounded-full px-3 py-2 text-base-content text-sm font-medium"
class="flex items-center gap-2 rounded-full border border-[#d5c7b0] bg-[#f3eee6] px-3 py-2 text-[#2f2418] text-sm font-medium shadow-[0_10px_24px_rgba(38,29,18,0.12)]"
@click="openPanel"
>
<Icon name="lucide:menu" size="16" />
@@ -125,11 +130,11 @@
</button>
<!-- Mobile view toggle - hide in info mode or when hideViewToggle -->
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 map-chip rounded-full p-1">
<div v-if="!isInfoMode && !hideViewToggle" class="flex gap-1 rounded-full border border-[#d5c7b0] bg-[#f3eee6] p-1 shadow-[0_10px_24px_rgba(38,29,18,0.12)]">
<button
v-if="showOffersToggle"
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
:class="mapViewMode === 'offers' ? 'bg-base-300' : 'hover:bg-base-200'"
:class="mapViewMode === 'offers' ? 'bg-[#2f2416]' : 'hover:bg-[#ece3d3]'"
@click="setMapViewMode('offers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #f97316">
@@ -139,7 +144,7 @@
<button
v-if="showHubsToggle"
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
:class="mapViewMode === 'hubs' ? 'bg-base-300' : 'hover:bg-base-200'"
:class="mapViewMode === 'hubs' ? 'bg-[#2f2416]' : 'hover:bg-[#ece3d3]'"
@click="setMapViewMode('hubs')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #22c55e">
@@ -149,7 +154,7 @@
<button
v-if="showSuppliersToggle"
class="flex items-center justify-center w-8 h-8 rounded-full transition-colors"
:class="mapViewMode === 'suppliers' ? 'bg-base-300' : 'hover:bg-base-200'"
:class="mapViewMode === 'suppliers' ? 'bg-[#2f2416]' : 'hover:bg-[#ece3d3]'"
@click="setMapViewMode('suppliers')"
>
<span class="w-5 h-5 rounded-full flex items-center justify-center" style="background-color: #3b82f6">
@@ -163,7 +168,7 @@
<Transition name="slide-up">
<div
v-if="isPanelOpen"
class="bg-base-100 text-base-content rounded-t-3xl border-t border-base-300 shadow-[0_-8px_40px_rgba(0,0,0,0.12)] transition-all duration-300 h-[60vh]"
class="rounded-t-3xl border-t border-[#d5c7b0] bg-[#f3eee6] text-[#2f2418] shadow-[0_-8px_40px_rgba(0,0,0,0.12)] transition-all duration-300 h-[60vh]"
>
<!-- Drag handle / close -->
<div

View File

@@ -1,347 +1,31 @@
<script setup lang="ts">
const route = useRoute()
const localePath = useLocalePath()
const isHomePage = computed(() => route.path === localePath('/'))
const isCatalogSection = computed(() => route.path.startsWith(localePath('/catalog')))
const isClientArea = computed(() => route.path.startsWith(localePath('/clientarea')))
const contentClass = computed(() => {
if (isCatalogSection.value || isHomePage.value) {
return 'flex-1 flex flex-col min-h-0'
}
return 'flex-1 flex flex-col min-h-0 px-3 lg:px-6'
})
const mainStyle = computed(() => {
if (isCatalogSection.value || isHomePage.value) return { paddingTop: '0' }
if (isClientArea.value) return { paddingTop: '116px', paddingBottom: '96px' }
return { paddingTop: '132px', paddingBottom: '96px' }
})
</script>
<template>
<div class="min-h-screen flex flex-col manager-logistics-shell">
<AiChatSidebar
:open="isChatOpen"
:width="chatWidth"
@close="isChatOpen = false"
/>
<div class="min-h-screen flex flex-col bg-base-100 text-base-content">
<AppHeader />
<div class="flex-1 flex flex-col" :style="contentStyle">
<!-- Fixed Header Container -->
<div class="header-glass fixed inset-x-0 top-0 z-50 border-0" :style="headerContainerStyle">
<div class="header-glass-backdrop" aria-hidden="true" />
<!-- MainNavigation -->
<MainNavigation
class="relative z-10"
:height="100"
:collapse-progress="isHomePage ? 0 : 1"
:hero-scroll-y="isHomePage ? heroScrollY : 0"
:hero-base-height="isHomePage ? heroBaseHeight : 0"
:session-checked="sessionChecked"
:logged-in="isLoggedIn"
:user-avatar-svg="userAvatarSvg"
:user-name="userName"
:user-initials="userInitials"
:theme="theme"
:user-data="userData"
:is-seller="isSeller"
:has-multiple-roles="hasMultipleRoles"
:current-role="currentRole"
:active-tokens="activeTokens"
:available-chips="availableChips"
:select-mode="selectMode"
:search-query="searchQuery"
:catalog-mode="catalogMode"
:product-label="productLabel ?? undefined"
:hub-label="hubLabel ?? undefined"
:quantity="quantity"
:can-search="canSearch"
:show-mode-toggle="true"
:show-active-mode="isCatalogSection"
:is-collapsed="isHomePage ? false : (isCatalogSection || isClientArea)"
:is-home-page="isHomePage"
:is-client-area="isClientArea"
:chat-open="isChatOpen"
@toggle-theme="toggleTheme"
@toggle-chat="isChatOpen = !isChatOpen"
@set-catalog-mode="setCatalogMode"
@sign-out="onClickSignOut"
@sign-in="signIn()"
@switch-team="switchToTeam"
@switch-role="switchToRole"
@start-select="startSelect"
@cancel-select="cancelSelect"
@edit-token="editFilter"
@remove-token="removeFilter"
@update:search-query="searchQuery = $event"
@update-quantity="setQuantity"
@search="onSearch"
/>
<!-- Sub Navigation (section-specific tabs) - only for non-catalog/non-home/non-clientarea sections -->
<SubNavigation
v-if="!isHomePage && !isCatalogSection && !isClientArea"
:section="currentSection"
/>
</div>
<!-- Page content - padding-top compensates for fixed header -->
<main class="flex-1 flex flex-col min-h-0 px-3 lg:px-6" :style="mainStyle">
<slot />
</main>
</div>
<main :class="contentClass" :style="mainStyle">
<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()
const router = useRouter()
const localePath = useLocalePath()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const isChatOpen = useState('ai-chat-open', () => false)
const chatWidth = computed(() => (isChatOpen.value ? 'clamp(240px, 15vw, 360px)' : '0px'))
const contentStyle = computed(() => ({
marginLeft: isChatOpen.value ? chatWidth.value : '0px',
width: isChatOpen.value ? `calc(100% - ${chatWidth.value})` : '100%',
transition: 'margin-left 250ms ease, width 250ms ease'
}))
// Catalog search state
const {
selectMode,
searchQuery,
activeTokens,
availableChips,
startSelect,
cancelSelect,
removeFilter,
editFilter,
setQuantity,
catalogMode,
setCatalogMode,
productLabel,
hubLabel,
quantity,
canSearch
} = useCatalogSearch()
// Collapsible header for catalog pages
const { headerOffset, isCollapsed } = useScrollCollapse(100)
// Hero scroll for home page
const {
scrollY: heroScrollY,
heroBaseHeight,
} = useHeroScroll()
// Theme state
const theme = useState<'silk' | 'night'>('theme', () => 'silk')
// User data state (shared across layouts)
interface SelectedLocation {
type: string
uuid: string
name: string
latitude: number
longitude: number
}
const userData = useState<{
id?: string
firstName?: string
lastName?: string
avatarId?: string
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: SelectedLocation | null }
activeTeamId?: string
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string; teamType?: 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'
})
// Role switching support
const buyerTeam = computed(() =>
userData.value?.teams?.find(t => t?.teamType === 'BUYER')
)
const sellerTeam = computed(() =>
userData.value?.teams?.find(t => t?.teamType === 'SELLER')
)
const hasBuyerTeam = computed(() => !!buyerTeam.value)
const hasSellerTeam = computed(() => !!sellerTeam.value)
const hasMultipleRoles = computed(() => hasBuyerTeam.value && hasSellerTeam.value)
const currentRole = computed(() =>
userData.value?.activeTeam?.teamType || 'BUYER'
)
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'
})
// Home page detection
const isHomePage = computed(() => {
return route.path === '/' || route.path === '/en' || route.path === '/ru'
})
// Catalog section detection (unified search, no SubNav needed)
const isCatalogSection = computed(() => {
return route.path.startsWith('/catalog') ||
route.path.startsWith('/en/catalog') ||
route.path.startsWith('/ru/catalog')
})
// Client area detection (cabinet tabs in MainNavigation, no SubNav needed)
const isClientArea = computed(() => {
return route.path.includes('/clientarea')
})
// Collapsible header logic - only for pages with SubNav
const hasSubNav = computed(() => !isHomePage.value && !isCatalogSection.value && !isClientArea.value)
const canCollapse = computed(() => hasSubNav.value)
const isHeaderCollapsed = computed(() => canCollapse.value && isCollapsed.value)
// Header container style - transform for SubNav pages
const headerContainerStyle = computed(() => {
if (hasSubNav.value) {
// SubNav pages: slide up on scroll
return { transform: `translateY(${headerOffset.value}px)` }
}
return {}
})
// Main content padding-top to compensate for fixed header
const mainStyle = computed(() => {
if (isCatalogSection.value) return { paddingTop: '0' }
if (isHomePage.value) return { paddingTop: '0' }
if (isClientArea.value) return { paddingTop: '116px' } // Header only, no SubNav
return { paddingTop: '154px' }
})
// Provide collapsed state to child components (CatalogPage needs it for map positioning)
provide('headerCollapsed', isHeaderCollapsed)
// 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; teamType?: 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 switchToRole = async (role: 'BUYER' | 'SELLER') => {
const targetTeam = role === 'SELLER' ? sellerTeam.value : buyerTeam.value
if (targetTeam?.id) {
await switchToTeam(targetTeam)
// Redirect to appropriate page when in client area
if (isClientArea.value) {
const targetPath = role === 'SELLER'
? '/clientarea/offers'
: '/clientarea/orders'
await navigateTo(localePath(targetPath))
}
}
}
const onClickSignOut = () => {
signOut(siteUrl)
}
const applyTheme = (value: 'silk' | 'night') => {
if (import.meta.client) {
document.documentElement.setAttribute('data-theme', value)
localStorage.setItem('theme', value)
}
}
onMounted(() => {
const stored = import.meta.client ? localStorage.getItem('theme') : null
if (stored === 'night' || stored === 'silk') {
theme.value = stored as 'silk' | 'night'
}
applyTheme(theme.value)
})
watch(theme, (value) => applyTheme(value))
const toggleTheme = () => {
theme.value = theme.value === 'night' ? 'silk' : 'night'
}
// Search handler for Quote mode - triggers search via shared state
const searchTrigger = useState<number>('catalog-search-trigger', () => 0)
const onSearch = () => {
// Navigate to catalog page if not there
if (!route.path.includes('/catalog')) {
router.push({ path: localePath('/catalog'), query: { ...route.query, mode: 'quote', select: 'product' } })
}
// Trigger search by incrementing the counter (page watches this)
searchTrigger.value++
}
</script>

File diff suppressed because it is too large Load Diff