feat(ui): copy logistics header/theme one-to-one

This commit is contained in:
Ruslan Bakiev
2026-04-21 10:34:54 +07:00
parent 670e9b7fd1
commit d3183bf6ad
11 changed files with 1052 additions and 236 deletions

View File

@@ -58,15 +58,13 @@ body {
linear-gradient(135deg, #f7f5f1 0%, #f1eee8 100%);
}
.glass-underlay,
.glass-soft {
.glass-underlay {
background: #ece3d3;
border: 1px solid #d7ccb7;
box-shadow: 0 14px 30px rgba(24, 20, 12, 0.1);
}
.glass-capsule,
.glass-bright {
.glass-capsule {
background: #e9decb;
border: 1px solid #d5c7b0;
box-shadow: 0 8px 22px rgba(24, 20, 12, 0.1);
@@ -77,18 +75,3 @@ body {
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 {
min-height: 100vh;
font-family: "Onest", "Avenir Next", "Trebuchet MS", sans-serif;
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%);
}

View File

@@ -1,133 +1,275 @@
<script setup lang="ts">
interface MenuLink {
label: string
to: string
icon: 'home' | 'catalog' | 'orders' | 'profile'
}
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 localePath = useLocalePath()
const switchLocalePath = useSwitchLocalePath()
const { locale, locales } = useI18n()
const { signIn, signOut, loggedIn, user } = useAuth()
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' || route.path === '/sign-in' || 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 ?? 'Профиль'))
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 { productLabel, hubLabel, supplierLabel, quantity, canSearch } = useCatalogSearch()
const logisticsSearch = reactive({
from: '',
to: '',
cargo: '',
})
function syncSearchFromRoute() {
hydrateFromQuery(route.query)
logisticsSearch.from = typeof route.query.from === 'string' ? route.query.from : isCalcPage.value ? calcDraft.value.from : ''
logisticsSearch.to = typeof route.query.to === 'string' ? route.query.to : isCalcPage.value ? calcDraft.value.to : ''
logisticsSearch.cargo = 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 fromIso = computed(() => inferCountryIso(logisticsSearch.from, 'CN'))
const toIso = computed(() => inferCountryIso(logisticsSearch.to, 'RU'))
const fromFlag = computed(() => isoToFlag(fromIso.value))
const toFlag = computed(() => isoToFlag(toIso.value))
const showAdminDock = computed(() => Boolean(showLogistics.value) && !isAuthPage.value)
// Fullscreen menu
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' },
const menuLinks = computed(() => {
return [
{ label: 'Мои заказы', 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
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(from: string, to: string, cargo: string) {
const currentQuery = route.query || {}
const patch = {
from,
to,
cargo,
}
if (isCalcPage.value) {
return {
...currentQuery,
...buildCalcQuery(patch),
}
}
return {
...currentQuery,
...(from ? { from } : {}),
...(to ? { to } : {}),
...(cargo ? { cargo } : {}),
}
}
async function submitHeaderSearch() {
const from = logisticsSearch.from.trim()
const to = logisticsSearch.to.trim()
const cargo = logisticsSearch.cargo.trim()
await navigateToLocalized({
path: '/catalog',
query: buildHeaderSearchQuery(from, to, cargo),
})
}
type StepRoute = 'from' | 'to' | 'cargo'
async function openStep(step: StepRoute) {
const from = logisticsSearch.from.trim()
const to = logisticsSearch.to.trim()
const cargo = logisticsSearch.cargo.trim()
const stepPath = step === 'from'
? '/catalog/product'
: step === 'to'
? '/catalog/destination'
: '/catalog/quantity'
await navigateToLocalized({
path: stepPath,
query: buildHeaderSearchQuery(from, to, cargo),
})
}
async function goToSignIn() {
await auth.signIn()
}
</script>
<template>
<header class="header-glass fixed inset-x-0 top-0 z-50 border-0">
<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="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]">
<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>
<!-- 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" />
<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>
<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="locale === 'en' ? 'Menu' : 'Меню'"
: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">
@@ -138,76 +280,57 @@ watch(() => route.fullPath, () => {
<div class="hidden lg:block" aria-hidden="true" />
<div class="lg:hidden">
<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('product')"
@click="openStep('from')"
>
{{ locale === 'en' ? 'Start Search' : 'Начать поиск' }}
{{ $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="localePath('/catalog')" class="btn h-11 min-h-0 rounded-full border-0 bg-transparent px-4 shadow-none">
{{ locale === 'en' ? 'Catalog' : 'Каталог' }}
<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 :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" />
<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="handleAuthAction"
>
{{ locale === 'en' ? 'Log in' : 'Войти' }}
</button>
<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="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">
<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">🛒</span>
<span class="shrink-0 text-base leading-none">{{ fromFlag }}</span>
<input
:value="searchProductLabel"
:value="logisticsSearch.from"
type="text"
class="w-full cursor-pointer"
:placeholder="locale === 'en' ? 'Product' : 'Товар'"
:placeholder="$t('ui.from')"
readonly
@focus.prevent="openStep('product')"
@click.prevent="openStep('product')"
>
@focus.prevent="openStep('from')"
@click.prevent="openStep('from')"
/>
</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>
<span class="shrink-0 text-base leading-none">{{ toFlag }}</span>
<input
:value="searchDestinationLabel"
:value="logisticsSearch.to"
type="text"
class="w-full cursor-pointer"
:placeholder="locale === 'en' ? 'Destination' : 'Назначение'"
:placeholder="$t('ui.to')"
readonly
@focus.prevent="openStep('destination')"
@click.prevent="openStep('destination')"
>
@focus.prevent="openStep('to')"
@click.prevent="openStep('to')"
/>
</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" />
@@ -215,79 +338,180 @@ watch(() => route.fullPath, () => {
<path d="M12 22V12" />
</svg>
<input
:value="searchQuantityLabel"
:value="logisticsSearch.cargo"
type="text"
class="w-full cursor-pointer"
:placeholder="locale === 'en' ? 'Volume' : 'Объем'"
:placeholder="$t('ui.cargo')"
readonly
@focus.prevent="openStep('quantity')"
@click.prevent="openStep('quantity')"
>
@focus.prevent="openStep('cargo')"
@click.prevent="openStep('cargo')"
/>
</label>
<button type="submit" class="btn btn-secondary h-11 min-h-0 rounded-full px-5">
{{ locale === 'en' ? 'Find' : 'Найти' }}
</button>
<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="!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">
<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">{{ locale === 'en' ? 'Home' : 'Домой' }}</span>
<span class="dock-label">Optovia</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" />
<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">{{ locale === 'en' ? 'Catalog' : 'Каталог' }}</span>
</NuxtLink>
<span class="dock-label">{{ $t('ui.menu') }}</span>
</button>
<NuxtLink :to="localePath('/clientarea/orders')" class="dock-item" :class="isBasePathActive('/clientarea/orders') ? 'dock-item--active' : ''" aria-label="Orders">
<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">{{ locale === 'en' ? 'Orders' : 'Заказы' }}</span>
<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="localePath('/clientarea/profile')"
:to="toLocalized('/clientarea/profile')"
class="dock-item"
:class="isBasePathActive('/clientarea/profile') ? 'dock-item--active' : ''"
aria-label="Profile"
: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">{{ locale === 'en' ? 'Profile' : 'Профиль' }}</span>
<span class="dock-label">{{ $t('ui.profile') }}</span>
</NuxtLink>
<button v-else class="dock-item" aria-label="Auth" @click="handleAuthAction">
<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" />
<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>
<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
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">
<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>
@@ -298,53 +522,41 @@ watch(() => route.fullPath, () => {
<NuxtLink
v-for="(link, i) in menuLinks"
:key="link.to"
:to="localePath(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 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" />
<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 === '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">
<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 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 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>
<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" />
<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>
<span class="text-base font-medium">
{{ isAuthenticated ? (locale === 'en' ? 'Log out' : 'Выйти') : (locale === 'en' ? 'Log in' : 'Войти') }}
</span>
</button>
</NuxtLink>
</nav>
</div>
</Transition>
@@ -377,6 +589,10 @@ watch(() => route.fullPath, () => {
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);
@@ -404,22 +620,25 @@ watch(() => route.fullPath, () => {
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;
@@ -450,6 +669,71 @@ watch(() => route.fullPath, () => {
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;
@@ -463,7 +747,7 @@ watch(() => route.fullPath, () => {
}
.dock-item--active {
color: #2f2416;
color: oklch(var(--s));
}
.dock-label {

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { AppLocale } from '~/composables/useLocaleCurrency'
const {
locale,
currency,
localeOptions,
currencyOptions,
languageCode,
currencyCode,
ratesProviderUrl,
setLocale,
setCurrency,
t,
} = useLocaleCurrency()
const isOpen = ref(false)
const switchLocalePath = useSwitchLocalePath()
function closeSelector() {
isOpen.value = false
}
async function changeLocale(code: AppLocale) {
const nextPath = switchLocalePath(code)
await setLocale(code)
if (nextPath) {
await navigateTo(nextPath)
}
}
</script>
<template>
<div class="relative">
<button
type="button"
class="btn h-11 min-h-0 gap-2 rounded-full border-0 bg-transparent px-3 text-xs font-black uppercase tracking-[0.08em] shadow-none transition hover:bg-white/70"
:aria-label="t('settings.open')"
:aria-expanded="String(isOpen)"
@click="isOpen = !isOpen"
>
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 0 20" />
<path d="M12 2a15.3 15.3 0 0 0 0 20" />
</svg>
<span>{{ languageCode }} · {{ currencyCode }}</span>
</button>
<div
v-if="isOpen"
class="absolute left-0 top-[calc(100%+0.7rem)] z-[110] w-[292px] rounded-[28px] bg-[#f3eee6] p-3 text-[#2f2418] shadow-[0_24px_60px_rgba(35,30,25,0.22)]"
>
<div class="rounded-[22px] bg-white p-3">
<p class="px-2 text-[11px] font-black uppercase tracking-[0.16em] text-[#8a7761]">{{ t('settings.language') }}</p>
<div class="mt-2 grid grid-cols-2 gap-1.5">
<button
v-for="item in localeOptions"
:key="item.code"
type="button"
class="rounded-2xl px-3 py-2 text-left text-sm font-bold transition"
:class="locale === item.code ? 'bg-[#2f2418] text-white' : 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#eee6dc]'"
@click="changeLocale(item.code)"
>
<span class="block">{{ item.nativeLabel }}</span>
<span class="mt-0.5 block text-[11px] opacity-65">{{ item.label }}</span>
</button>
</div>
</div>
<div class="mt-2 rounded-[22px] bg-white p-3">
<p class="px-2 text-[11px] font-black uppercase tracking-[0.16em] text-[#8a7761]">{{ t('settings.currency') }}</p>
<div class="mt-2 grid grid-cols-2 gap-1.5">
<button
v-for="item in currencyOptions"
:key="item.code"
type="button"
class="rounded-2xl px-3 py-2 text-left text-sm font-bold transition"
:class="currency === item.code ? 'bg-[#2f2418] text-white' : 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#eee6dc]'"
@click="setCurrency(item.code)"
>
<span class="block">{{ item.code }}</span>
<span class="mt-0.5 block text-[11px] opacity-65">{{ item.symbol }} · {{ item.label }}</span>
</button>
</div>
</div>
<button
type="button"
class="mt-2 h-10 w-full rounded-full bg-[#2f2418] text-sm font-black text-white transition hover:bg-[#493823]"
@click="closeSelector"
>
{{ t('settings.apply') }}
</button>
<a
:href="ratesProviderUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-2 block px-2 text-center text-[10px] font-bold uppercase tracking-[0.12em] text-[#8a7761] transition hover:text-[#2f2418]"
>
{{ t('settings.ratesBy') }}
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,86 @@
type CalcSearchDraft = {
from: string
to: string
cargo: string
fromLat: number | null
fromLng: number | null
toLat: number | null
toLng: number | null
}
type CalcSearchDraftPatch = Partial<CalcSearchDraft>
const initialCalcSearchDraft = (): CalcSearchDraft => ({
from: '',
to: '',
cargo: '',
fromLat: null,
fromLng: null,
toLat: null,
toLng: null,
})
function stringParam(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function numberParam(value: unknown) {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
export function useCalcSearchDraft() {
const draft = useState<CalcSearchDraft>('calc-search-draft', initialCalcSearchDraft)
function hydrateFromQuery(query: Record<string, unknown>) {
const nextDraft = { ...draft.value }
const from = stringParam(query.from)
const to = stringParam(query.to)
const cargo = stringParam(query.cargo)
const fromLat = numberParam(query.fromLat)
const fromLng = numberParam(query.fromLng)
const toLat = numberParam(query.toLat)
const toLng = numberParam(query.toLng)
if (from) nextDraft.from = from
if (to) nextDraft.to = to
if (cargo) nextDraft.cargo = cargo
if (fromLat !== null) nextDraft.fromLat = fromLat
if (fromLng !== null) nextDraft.fromLng = fromLng
if (toLat !== null) nextDraft.toLat = toLat
if (toLng !== null) nextDraft.toLng = toLng
draft.value = nextDraft
}
function patchDraft(patch: CalcSearchDraftPatch) {
draft.value = {
...draft.value,
...patch,
}
}
function buildQuery(patch: CalcSearchDraftPatch = {}) {
const nextDraft = {
...draft.value,
...patch,
}
return {
...(nextDraft.from.trim() ? { from: nextDraft.from.trim() } : {}),
...(nextDraft.to.trim() ? { to: nextDraft.to.trim() } : {}),
...(nextDraft.cargo.trim() ? { cargo: nextDraft.cargo.trim() } : {}),
...(nextDraft.fromLat !== null ? { fromLat: String(nextDraft.fromLat) } : {}),
...(nextDraft.fromLng !== null ? { fromLng: String(nextDraft.fromLng) } : {}),
...(nextDraft.toLat !== null ? { toLat: String(nextDraft.toLat) } : {}),
...(nextDraft.toLng !== null ? { toLng: String(nextDraft.toLng) } : {}),
}
}
return {
draft,
hydrateFromQuery,
patchDraft,
buildQuery,
}
}

View File

@@ -0,0 +1,188 @@
export type AppLocale = 'ru' | 'en'
export type AppCurrency = 'USD' | 'RUB' | 'CNY' | 'EUR' | 'AED'
export type CurrencyRatesResponse = {
baseCurrency: 'USD'
rates: Record<AppCurrency, number>
updatedAt: string
nextUpdateAt: string
provider: string
documentation: string
termsOfUse: string
attributionUrl: string
}
export type LocaleOption = {
code: AppLocale
label: string
nativeLabel: string
}
export type CurrencyOption = {
code: AppCurrency
label: string
symbol: string
}
type TranslationKey =
| 'settings.language'
| 'settings.currency'
| 'settings.open'
| 'settings.apply'
| 'settings.ratesProvider'
| 'settings.ratesBy'
const LOCALE_MAP: Record<AppLocale, string> = {
ru: 'ru-RU',
en: 'en-US',
}
const CURRENCY_SYMBOLS: Record<AppCurrency, string> = {
USD: '$',
RUB: '₽',
CNY: '¥',
EUR: '€',
AED: 'د.إ',
}
const LOCALES: AppLocale[] = ['ru', 'en']
const CURRENCIES: AppCurrency[] = ['USD', 'RUB', 'CNY', 'EUR', 'AED']
const localeCodes = new Set<AppLocale>(LOCALES)
const currencyCodes = new Set<AppCurrency>(CURRENCIES)
function normalizeLocale(value: unknown): AppLocale {
return localeCodes.has(value as AppLocale) ? value as AppLocale : 'ru'
}
function assertCurrency(value: unknown): AppCurrency {
const normalized = String(value || '').trim().toUpperCase()
if (!currencyCodes.has(normalized as AppCurrency)) {
throw createError({
statusCode: 500,
statusMessage: `Unsupported currency: ${normalized || 'empty'}`,
})
}
return normalized as AppCurrency
}
function normalizeCurrency(value: unknown): AppCurrency {
const normalized = String(value || '').trim().toUpperCase()
return currencyCodes.has(normalized as AppCurrency) ? normalized as AppCurrency : 'USD'
}
export function useLocaleCurrency() {
const { locale: i18nLocale, setLocale: setI18nLocale, t: i18nT } = useI18n()
const currencyCookie = useCookie<AppCurrency>('ex_currency', {
default: () => 'USD',
sameSite: 'lax',
path: '/',
})
const currencyRates = useState<CurrencyRatesResponse | null>('currency-rates', () => null)
const locale = computed<AppLocale>({
get: () => normalizeLocale(i18nLocale.value),
set: (value) => {
i18nLocale.value = normalizeLocale(value)
},
})
const currency = computed<AppCurrency>({
get: () => normalizeCurrency(currencyCookie.value),
set: (value) => {
currencyCookie.value = normalizeCurrency(value)
},
})
const intlLocale = computed(() => LOCALE_MAP[locale.value])
const localeOptions = computed<LocaleOption[]>(() => LOCALES.map(code => ({
code,
label: i18nT(`settings.locales.${code}.label`),
nativeLabel: i18nT(`settings.locales.${code}.nativeLabel`),
})))
const currencyOptions = computed<CurrencyOption[]>(() => CURRENCIES.map(code => ({
code,
label: i18nT(`settings.currencies.${code}`),
symbol: CURRENCY_SYMBOLS[code],
})))
const languageCode = computed(() => locale.value.toUpperCase())
const currencyCode = computed(() => currency.value)
const ratesProviderUrl = computed(() => currencyRates.value?.attributionUrl || 'https://www.exchangerate-api.com')
async function setLocale(value: AppLocale) {
const normalizedLocale = normalizeLocale(value)
locale.value = normalizedLocale
await setI18nLocale(normalizedLocale)
}
function setCurrency(value: AppCurrency) {
currency.value = value
}
function t(key: TranslationKey) {
return i18nT(key)
}
function getRates() {
if (!currencyRates.value) {
throw createError({
statusCode: 500,
statusMessage: 'Currency rates are not loaded',
})
}
return currencyRates.value.rates
}
function convertMoney(value: number, sourceCurrency: AppCurrency = 'USD', targetCurrency: AppCurrency = currency.value) {
const rates = getRates()
const amountInUsd = value / rates[sourceCurrency]
return amountInUsd * rates[targetCurrency]
}
function formatMoney(value: number, sourceCurrency: string = 'USD') {
const normalizedSourceCurrency = assertCurrency(sourceCurrency)
const convertedValue = convertMoney(value, normalizedSourceCurrency, currency.value)
return new Intl.NumberFormat(intlLocale.value, {
style: 'currency',
currency: currency.value,
maximumFractionDigits: currency.value === 'RUB' ? 0 : 0,
}).format(convertedValue)
}
function formatDate(value: Date | string, options: Intl.DateTimeFormatOptions = {}) {
const date = value instanceof Date ? value : new Date(value)
return new Intl.DateTimeFormat(intlLocale.value, options).format(date)
}
function formatDateTime(value: Date | string) {
return formatDate(value, {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}
return {
locale,
currency,
intlLocale,
localeOptions,
currencyOptions,
languageCode,
currencyCode,
setLocale,
setCurrency,
convertMoney,
formatMoney,
formatDate,
formatDateTime,
currencyRates,
ratesProviderUrl,
t,
}
}

View File

@@ -0,0 +1,70 @@
type LocalizedRouteTarget = string | {
path?: string
name?: string
params?: Record<string, unknown>
query?: Record<string, unknown>
hash?: string
}
// This composable is used in route middleware, where `useI18n()` is not available.
const LOCALE_CODES = ['ru', 'en']
function normalizePath(path: string) {
return path || '/'
}
export function stripLocalePrefix(path: string, localeCodes: string[]) {
const normalizedPath = normalizePath(path)
const localePattern = localeCodes.map(code => code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
if (!localePattern) return normalizedPath
const match = normalizedPath.match(new RegExp(`^/(${localePattern})(?=/|$)`))
if (!match) return normalizedPath
const nextPath = normalizedPath.slice(match[0].length)
return nextPath || '/'
}
export function useLocalizedNavigation() {
const route = useRoute()
const localePath = useLocalePath()
const basePath = computed(() => stripLocalePrefix(route.path, LOCALE_CODES))
function toLocalized(to: LocalizedRouteTarget) {
if (typeof to === 'string') {
return to.startsWith('/') ? localePath(to) : to
}
if (to.path) {
return {
...to,
path: to.path.startsWith('/') ? localePath(to.path) : to.path,
}
}
return to
}
function isBasePathActive(target: string, exact = false) {
const normalizedTarget = normalizePath(target)
if (exact) {
return basePath.value === normalizedTarget
}
return basePath.value === normalizedTarget || basePath.value.startsWith(`${normalizedTarget}/`)
}
function navigateToLocalized(to: LocalizedRouteTarget, options?: Parameters<typeof navigateTo>[1]) {
return navigateTo(toLocalized(to), options)
}
return {
basePath,
isBasePathActive,
localePath,
navigateToLocalized,
toLocalized,
}
}

View File

@@ -0,0 +1,27 @@
{
"settings": {
"language": "Language",
"currency": "Currency",
"open": "Open settings",
"apply": "Apply",
"ratesProvider": "Rates provider",
"ratesBy": "Rates by ExchangeRate API",
"locales": {
"ru": {
"label": "Russian",
"nativeLabel": "Русский"
},
"en": {
"label": "English",
"nativeLabel": "English"
}
},
"currencies": {
"USD": "US Dollar",
"RUB": "Russian Ruble",
"CNY": "Chinese Yuan",
"EUR": "Euro",
"AED": "UAE Dirham"
}
}
}

20
i18n/locales/en/ui.json Normal file
View File

@@ -0,0 +1,20 @@
{
"ui": {
"menu": "Menu",
"calculate": "Calculate",
"my_orders": "My orders",
"log_in": "Log in",
"login": "Log in",
"profile": "Profile",
"from": "From",
"to": "To",
"cargo": "Cargo",
"find": "Find",
"manager_navigation": "Manager navigation",
"orders": "Orders",
"quotations": "Quotations",
"hubs": "Hubs",
"users": "Users",
"manager": "Manager"
}
}

View File

@@ -0,0 +1,27 @@
{
"settings": {
"language": "Язык",
"currency": "Валюта",
"open": "Открыть настройки",
"apply": "Применить",
"ratesProvider": "Поставщик курсов",
"ratesBy": "Курсы от ExchangeRate API",
"locales": {
"ru": {
"label": "Russian",
"nativeLabel": "Русский"
},
"en": {
"label": "English",
"nativeLabel": "English"
}
},
"currencies": {
"USD": "Доллар США",
"RUB": "Российский рубль",
"CNY": "Китайский юань",
"EUR": "Евро",
"AED": "Дирхам ОАЭ"
}
}
}

20
i18n/locales/ru/ui.json Normal file
View File

@@ -0,0 +1,20 @@
{
"ui": {
"menu": "Меню",
"calculate": "Рассчитать",
"my_orders": "Мои заказы",
"log_in": "Войти",
"login": "Войти",
"profile": "Профиль",
"from": "Откуда",
"to": "Куда",
"cargo": "Груз",
"find": "Найти",
"manager_navigation": "Навигация менеджера",
"orders": "Заказы",
"quotations": "Котировки",
"hubs": "Хабы",
"users": "Пользователи",
"manager": "Менеджер"
}
}

View File

@@ -51,6 +51,8 @@ export default defineNuxtConfig({
'ru/clientTeam.json',
'ru/clientTeamSwitch.json',
'ru/common.json',
'ru/ui.json',
'ru/settings.json',
'ru/cta.json',
'ru/dashboard.json',
'ru/footer.json',
@@ -114,6 +116,8 @@ export default defineNuxtConfig({
'en/clientTeam.json',
'en/clientTeamSwitch.json',
'en/common.json',
'en/ui.json',
'en/settings.json',
'en/cta.json',
'en/dashboard.json',
'en/footer.json',