Add topnav layout with MainNavigation, SubNavigation, GlobalSearchBar, SplitLayout
Some checks failed
Build Docker Image / build (push) Failing after 1m25s

This commit is contained in:
Ruslan Bakiev
2026-01-08 01:03:07 +07:00
parent 4235c9f1e9
commit 737ec91473
11 changed files with 775 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
<template>
<div class="flex-1 flex flex-col lg:flex-row min-h-0">
<!-- List panel -->
<div
class="flex-1 overflow-auto lg:w-3/5 lg:max-w-none"
:class="{ 'hidden lg:block': mobileView === 'map' }"
>
<slot name="list" />
</div>
<!-- Map panel -->
<div
class="lg:w-2/5 lg:sticky lg:top-0 lg:h-[calc(100vh-13rem)]"
:class="{ 'hidden lg:block': mobileView === 'list', 'flex-1': mobileView === 'map' }"
>
<slot name="map" />
</div>
<!-- Mobile toggle -->
<div class="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-30">
<div class="btn-group shadow-lg">
<button
class="btn btn-sm"
:class="{ 'btn-active': mobileView === 'list' }"
@click="mobileView = 'list'"
>
<Icon name="lucide:list" size="16" />
{{ $t('common.list') }}
</button>
<button
class="btn btn-sm"
:class="{ 'btn-active': mobileView === 'map' }"
@click="mobileView = 'map'"
>
<Icon name="lucide:map" size="16" />
{{ $t('common.map') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
defaultView?: 'list' | 'map'
}>()
const mobileView = ref<'list' | 'map'>(props.defaultView || 'list')
</script>

View File

@@ -0,0 +1,217 @@
<template>
<header class="sticky top-0 z-40 bg-base-100 border-b border-base-300">
<div class="flex items-center justify-between h-16 px-4 lg:px-6">
<!-- Left: Logo -->
<div class="flex items-center">
<NuxtLink :to="localePath('/')" class="flex items-center gap-2">
<img src="/logo.svg" alt="Optovia" class="h-8" />
<span class="font-bold text-xl hidden sm:inline">Optovia</span>
</NuxtLink>
</div>
<!-- Center: Main tabs -->
<nav class="hidden md:flex items-center gap-1">
<NuxtLink
v-for="tab in visibleTabs"
:key="tab.key"
:to="localePath(tab.path)"
class="tab-item"
:class="{ 'tab-active': isActiveTab(tab.key) }"
>
<Icon v-if="tab.icon" :name="tab.icon" size="18" class="mr-1" />
{{ tab.label }}
</NuxtLink>
</nav>
<!-- Right: Globe + Team + User -->
<div class="flex items-center gap-2">
<!-- Globe (language/currency) dropdown -->
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle">
<Icon name="lucide:globe" size="20" />
</button>
<div tabindex="0" class="dropdown-content bg-base-100 rounded-box z-50 w-52 p-4 shadow-lg border border-base-300">
<div class="font-semibold mb-2">{{ $t('common.language') }}</div>
<div class="flex gap-2 mb-4">
<NuxtLink
v-for="loc in locales"
:key="loc.code"
:to="switchLocalePath(loc.code)"
class="btn btn-sm"
:class="locale === loc.code ? 'btn-primary' : 'btn-ghost'"
>
{{ loc.code.toUpperCase() }}
</NuxtLink>
</div>
<div class="font-semibold mb-2">{{ $t('common.theme') }}</div>
<button
class="btn btn-sm btn-ghost w-full justify-start"
@click="$emit('toggle-theme')"
>
<Icon :name="theme === 'night' ? 'lucide:sun' : 'lucide:moon'" size="16" />
{{ theme === 'night' ? $t('common.theme_light') : $t('common.theme_dark') }}
</button>
</div>
</div>
<!-- Team dropdown -->
<template v-if="loggedIn && userData?.teams?.length">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost gap-2">
<Icon name="lucide:building-2" size="18" />
<span class="hidden sm:inline max-w-32 truncate">
{{ userData?.activeTeam?.name || $t('common.selectTeam') }}
</span>
<Icon name="lucide:chevron-down" size="14" />
</button>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-56 p-2 shadow-lg border border-base-300">
<li class="menu-title"><span>{{ $t('common.teams') }}</span></li>
<li v-for="team in userData?.teams" :key="team.id">
<a
@click="$emit('switch-team', team)"
:class="{ active: team.id === userData?.activeTeamId }"
>
{{ team.name }}
</a>
</li>
<div class="divider my-1"></div>
<li>
<NuxtLink :to="localePath('/clientarea/team')">
<Icon name="lucide:settings" size="16" />
{{ $t('cabinetNav.teamSettings') }}
</NuxtLink>
</li>
</ul>
</div>
</template>
<!-- User menu -->
<template v-if="sessionChecked">
<template v-if="loggedIn">
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full">
<div v-if="userAvatarSvg" v-html="userAvatarSvg" class="w-full h-full" />
<div
v-else
class="w-full h-full bg-primary flex items-center justify-center text-primary-content font-bold text-sm"
>
{{ userInitials }}
</div>
</div>
</div>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-300"
>
<li class="menu-title">
<span>{{ userName }}</span>
</li>
<li>
<NuxtLink :to="localePath('/clientarea/profile')">
<Icon name="lucide:user" size="16" />
{{ $t('dashboard.profile') }}
</NuxtLink>
</li>
<div class="divider my-1"></div>
<li>
<a @click="$emit('sign-out')" class="text-error">
<Icon name="lucide:log-out" size="16" />
{{ $t('auth.logout') }}
</a>
</li>
</ul>
</div>
</template>
<template v-else>
<button @click="$emit('sign-in')" class="btn btn-primary btn-sm">
{{ $t('auth.login') }}
</button>
</template>
</template>
</div>
</div>
<!-- Mobile tabs (shown below header on small screens) -->
<nav class="md:hidden flex items-center justify-center gap-1 py-2 border-t border-base-300 overflow-x-auto">
<NuxtLink
v-for="tab in visibleTabs"
:key="tab.key"
:to="localePath(tab.path)"
class="tab-item-mobile"
:class="{ 'tab-active': isActiveTab(tab.key) }"
>
<Icon v-if="tab.icon" :name="tab.icon" size="16" />
<span class="text-xs">{{ tab.label }}</span>
</NuxtLink>
</nav>
</header>
</template>
<script setup lang="ts">
const props = defineProps<{
sessionChecked?: boolean
loggedIn?: boolean
userAvatarSvg?: string
userName?: string
userInitials?: string
theme?: 'default' | 'night'
userData?: {
id?: string
activeTeam?: { name?: string; teamType?: string }
activeTeamId?: string
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
} | null
isSeller?: boolean
}>()
defineEmits(['toggle-theme', 'sign-out', 'sign-in', 'switch-team'])
const localePath = useLocalePath()
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const route = useRoute()
const { t } = useI18n()
const tabs = computed(() => [
{ key: 'catalog', label: t('cabinetNav.catalog'), path: '/catalog/offers', icon: 'lucide:search', auth: false },
{ key: 'orders', label: t('cabinetNav.orders'), path: '/clientarea/orders', icon: 'lucide:package', auth: true },
{ key: 'seller', label: t('cabinetNav.seller'), path: '/clientarea/offers', icon: 'lucide:store', auth: true, seller: true },
])
const visibleTabs = computed(() => {
return tabs.value.filter(tab => {
if (tab.auth && !props.loggedIn) return false
if (tab.seller && !props.isSeller) return false
return true
})
})
const isActiveTab = (key: string) => {
const path = route.path
if (key === 'catalog') return path.startsWith('/catalog') || path === '/'
if (key === 'orders') return path.includes('/clientarea/orders') || path.includes('/clientarea/addresses') || path.includes('/clientarea/billing')
if (key === 'seller') return path.includes('/clientarea/offers')
return false
}
</script>
<style scoped>
.tab-item {
@apply px-4 py-2 rounded-full font-medium text-sm transition-colors;
@apply hover:bg-base-200;
}
.tab-item.tab-active {
@apply bg-base-200 text-primary;
}
.tab-item-mobile {
@apply flex flex-col items-center gap-1 px-4 py-2 rounded-lg;
@apply hover:bg-base-200;
}
.tab-item-mobile.tab-active {
@apply bg-base-200 text-primary;
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<nav v-if="items.length > 0" class="bg-base-100 border-b border-base-300">
<div class="flex items-center justify-center gap-1 py-2 px-4 overflow-x-auto">
<NuxtLink
v-for="item in items"
:key="item.path"
:to="localePath(item.path)"
class="subnav-item"
:class="{ active: isActive(item.path) }"
>
<Icon v-if="item.icon" :name="item.icon" size="16" class="mr-1.5" />
{{ item.label }}
</NuxtLink>
</div>
</nav>
</template>
<script setup lang="ts">
const props = defineProps<{
section: 'catalog' | 'orders' | 'seller' | 'settings'
}>()
const localePath = useLocalePath()
const route = useRoute()
const { t } = useI18n()
const sectionItems = computed(() => ({
catalog: [
{ label: t('cabinetNav.offers'), path: '/catalog/offers', icon: 'lucide:tag' },
{ label: t('cabinetNav.suppliers'), path: '/catalog/suppliers', icon: 'lucide:users' },
{ label: t('cabinetNav.hubs'), path: '/catalog/hubs', icon: 'lucide:warehouse' },
],
orders: [
{ label: t('cabinetNav.orders'), path: '/clientarea/orders', icon: 'lucide:package' },
{ label: t('cabinetNav.addresses'), path: '/clientarea/addresses', icon: 'lucide:map-pin' },
{ label: t('cabinetNav.billing'), path: '/clientarea/billing', icon: 'lucide:credit-card' },
],
seller: [
{ label: t('cabinetNav.myOffers'), path: '/clientarea/offers', icon: 'lucide:tag' },
],
settings: [
{ label: t('cabinetNav.profile'), path: '/clientarea/profile', icon: 'lucide:user' },
{ label: t('cabinetNav.team'), path: '/clientarea/team', icon: 'lucide:users' },
],
}))
const items = computed(() => sectionItems.value[props.section] || [])
const isActive = (path: string) => {
return route.path === localePath(path) || route.path.startsWith(localePath(path) + '/')
}
</script>
<style scoped>
.subnav-item {
@apply px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap;
@apply text-base-content/70 hover:text-base-content hover:bg-base-200;
}
.subnav-item.active {
@apply text-primary bg-primary/10;
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="bg-base-100 py-4 px-4 lg:px-6">
<div class="flex items-center justify-center">
<form
@submit.prevent="handleSearch"
class="flex items-center bg-base-100 rounded-full border border-base-300 shadow-sm hover:shadow-md transition-shadow"
>
<!-- Product field -->
<div class="search-field border-r border-base-300">
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.product') }}
</label>
<div class="dropdown w-full">
<input
v-model="productQuery"
type="text"
:placeholder="$t('search.product_placeholder')"
class="w-full bg-transparent outline-none text-sm"
@focus="showProductDropdown = true"
@input="filterProducts"
/>
<ul
v-if="showProductDropdown && filteredProducts.length > 0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-64 max-h-60 overflow-auto p-2 shadow-lg border border-base-300 mt-2"
>
<li v-for="product in filteredProducts" :key="product.uuid">
<a @click="selectProduct(product)">
{{ product.name }}
<span class="text-xs text-base-content/50">{{ product.categoryName }}</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Quantity field -->
<div class="search-field border-r border-base-300">
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.quantity') }}
</label>
<div class="flex items-center gap-1">
<input
v-model="quantity"
type="number"
min="1"
:placeholder="$t('search.quantity_placeholder')"
class="w-16 bg-transparent outline-none text-sm"
/>
<select v-model="unit" class="bg-transparent outline-none text-sm text-base-content/70">
<option value="t">{{ $t('units.t') }}</option>
<option value="kg">{{ $t('units.kg') }}</option>
</select>
</div>
</div>
<!-- Destination field -->
<div class="search-field">
<label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.destination') }}
</label>
<div class="dropdown w-full">
<input
v-model="destinationQuery"
type="text"
:placeholder="$t('search.destination_placeholder')"
class="w-full bg-transparent outline-none text-sm"
@focus="showDestinationDropdown = true"
@input="searchDestinations"
/>
<ul
v-if="showDestinationDropdown && destinations.length > 0"
class="dropdown-content menu bg-base-100 rounded-box z-50 w-64 max-h-60 overflow-auto p-2 shadow-lg border border-base-300 mt-2"
>
<li v-for="dest in destinations" :key="dest.uuid">
<a @click="selectDestination(dest)">
{{ dest.name }}
<span class="text-xs text-base-content/50">{{ dest.country }}</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Search button -->
<button
type="submit"
class="btn btn-primary btn-circle ml-2 mr-1"
:disabled="!canSearch"
>
<Icon name="lucide:search" size="18" />
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
search: [params: { productUuid?: string; quantity?: number; unit?: string; destinationUuid?: string }]
}>()
const router = useRouter()
const localePath = useLocalePath()
// Product search
const productQuery = ref('')
const selectedProduct = ref<{ uuid: string; name: string; categoryName?: string } | null>(null)
const showProductDropdown = ref(false)
const allProducts = ref<Array<{ uuid: string; name: string; categoryName?: string }>>([])
const filteredProducts = computed(() => {
if (!productQuery.value) return allProducts.value.slice(0, 10)
const q = productQuery.value.toLowerCase()
return allProducts.value.filter(p =>
p.name.toLowerCase().includes(q) ||
p.categoryName?.toLowerCase().includes(q)
).slice(0, 10)
})
const filterProducts = () => {
showProductDropdown.value = true
}
const selectProduct = (product: typeof selectedProduct.value) => {
selectedProduct.value = product
productQuery.value = product?.name || ''
showProductDropdown.value = false
}
// Quantity
const quantity = ref<number | undefined>()
const unit = ref('t')
// Destination search
const destinationQuery = ref('')
const selectedDestination = ref<{ uuid: string; name: string; country?: string } | null>(null)
const showDestinationDropdown = ref(false)
const destinations = ref<Array<{ uuid: string; name: string; country?: string }>>([])
const searchDestinations = async () => {
showDestinationDropdown.value = true
// TODO: implement destination search via GraphQL
}
const selectDestination = (dest: typeof selectedDestination.value) => {
selectedDestination.value = dest
destinationQuery.value = dest?.name || ''
showDestinationDropdown.value = false
}
// Can search
const canSearch = computed(() => {
return !!selectedProduct.value
})
// Search handler
const handleSearch = () => {
if (!canSearch.value) return
const params: Record<string, string> = {}
if (selectedProduct.value?.uuid) {
params.product = selectedProduct.value.uuid
}
if (quantity.value) {
params.quantity = String(quantity.value)
params.unit = unit.value
}
if (selectedDestination.value?.uuid) {
params.destination = selectedDestination.value.uuid
}
router.push({
path: localePath('/catalog/offers'),
query: params
})
emit('search', {
productUuid: selectedProduct.value?.uuid,
quantity: quantity.value,
unit: unit.value,
destinationUuid: selectedDestination.value?.uuid
})
}
// Load products on mount
onMounted(async () => {
try {
const { query } = useGraphQL()
const { GetProductsDocument } = await import('~/composables/graphql/exchange/products-generated')
const result = await query(GetProductsDocument, {}, 'exchange', 'public')
allProducts.value = result.getProducts || []
} catch (error) {
console.error('Failed to load products:', error)
}
})
// Close dropdowns on click outside
onMounted(() => {
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.dropdown')) {
showProductDropdown.value = false
showDestinationDropdown.value = false
}
}
document.addEventListener('click', handler)
onUnmounted(() => document.removeEventListener('click', handler))
})
</script>
<style scoped>
.search-field {
@apply flex flex-col px-4 py-2 min-w-32;
}
.search-field:first-child {
@apply pl-6 rounded-l-full;
}
.search-field:hover {
@apply bg-base-200/50;
}
</style>

181
app/layouts/topnav.vue Normal file
View File

@@ -0,0 +1,181 @@
<template>
<div class="min-h-screen flex flex-col bg-base-200">
<!-- Main Navigation (Logo + Tabs + User) -->
<MainNavigation
:session-checked="sessionChecked"
:logged-in="isLoggedIn"
:user-avatar-svg="userAvatarSvg"
:user-name="userName"
:user-initials="userInitials"
:theme="theme"
:user-data="userData"
:is-seller="isSeller"
@toggle-theme="toggleTheme"
@sign-out="onClickSignOut"
@sign-in="signIn()"
@switch-team="switchToTeam"
/>
<!-- Sub Navigation (section-specific tabs) -->
<SubNavigation :section="currentSection" />
<!-- Global Search Bar -->
<GlobalSearchBar v-if="showSearch" class="border-b border-base-300" />
<!-- Page content -->
<main class="flex-1 flex flex-col min-h-0">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
const runtimeConfig = useRuntimeConfig()
const siteUrl = runtimeConfig.public.siteUrl || 'https://optovia.ru/'
const { signIn, signOut, loggedIn, fetch: fetchSession } = useAuth()
const route = useRoute()
// Theme state
const theme = useState<'default' | 'night'>('theme', () => 'default')
// User data state (shared across layouts)
const userData = useState<{
id?: string
firstName?: string
lastName?: string
avatarId?: string
activeTeam?: { name?: string; teamType?: string; logtoOrgId?: string; selectedLocation?: any }
activeTeamId?: string
teams?: Array<{ id?: string; name?: string; logtoOrgId?: string }>
} | null>('me', () => null)
const sessionChecked = ref(false)
const userAvatarSvg = useState('user-avatar-svg', () => '')
const lastAvatarSeed = useState('user-avatar-seed', () => '')
const isSeller = computed(() => {
return userData.value?.activeTeam?.teamType === 'SELLER'
})
const isLoggedIn = computed(() => loggedIn.value || !!userData.value?.id)
const userName = computed(() => {
return userData.value?.firstName || 'User'
})
const userInitials = computed(() => {
const first = userData.value?.firstName?.charAt(0) || ''
const last = userData.value?.lastName?.charAt(0) || ''
if (first || last) return (first + last).toUpperCase()
return '?'
})
// Determine current section from route
const currentSection = computed(() => {
const path = route.path
if (path.startsWith('/catalog') || path === '/') return 'catalog'
if (path.includes('/clientarea/offers')) return 'seller'
if (path.includes('/clientarea/orders') || path.includes('/clientarea/addresses') || path.includes('/clientarea/billing')) return 'orders'
if (path.includes('/clientarea')) return 'settings'
return 'catalog'
})
// Show search bar on catalog pages
const showSearch = computed(() => {
return currentSection.value === 'catalog'
})
// Avatar generation
const generateUserAvatar = async (seed: string) => {
if (!seed) return
try {
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
if (response.ok) {
userAvatarSvg.value = await response.text()
lastAvatarSeed.value = seed
}
} catch (error) {
console.error('Error generating avatar:', error)
}
}
const { setActiveTeam } = useActiveTeam()
const { mutate } = useGraphQL()
const locationStore = useLocationStore()
const syncUserUi = async () => {
if (!userData.value) {
locationStore.clear()
return
}
if (userData.value.activeTeamId && userData.value.activeTeam?.logtoOrgId) {
setActiveTeam(userData.value.activeTeamId, userData.value.activeTeam.logtoOrgId)
}
const seed = userData.value.avatarId || userData.value.id || userData.value.firstName || 'default'
if (!userAvatarSvg.value || lastAvatarSeed.value !== seed) {
userAvatarSvg.value = ''
await generateUserAvatar(seed)
}
locationStore.setFromUserData(userData.value.activeTeam?.selectedLocation)
}
watch(userData, () => {
void syncUserUi()
}, { immediate: true })
// Check session
await fetchSession().catch(() => {})
sessionChecked.value = true
const switchToTeam = async (team: { id?: string; logtoOrgId?: string; name?: string }) => {
if (!team?.id) return
try {
const { SwitchTeamDocument } = await import('~/composables/graphql/user/teams-generated')
const result = await mutate(SwitchTeamDocument, { teamId: team.id }, 'user', 'teams')
if (result.switchTeam?.user && userData.value) {
userData.value.activeTeam = team as typeof userData.value.activeTeam
userData.value.activeTeamId = team.id
if (team.logtoOrgId) {
setActiveTeam(team.id, team.logtoOrgId)
}
}
} catch (err) {
console.error('Failed to switch team:', err)
}
}
const onClickSignOut = () => {
signOut(siteUrl)
}
const applyTheme = (value: 'default' | 'night') => {
if (import.meta.client) {
if (value === 'default') {
document.documentElement.removeAttribute('data-theme')
} else {
document.documentElement.setAttribute('data-theme', value)
}
localStorage.setItem('theme', value)
}
}
onMounted(() => {
const stored = import.meta.client ? localStorage.getItem('theme') : null
if (stored === 'night' || stored === 'default') {
theme.value = stored
}
applyTheme(theme.value)
})
watch(theme, (value) => applyTheme(value))
const toggleTheme = () => {
theme.value = theme.value === 'night' ? 'default' : 'night'
}
</script>

View File

@@ -1,11 +1,17 @@
{
"cabinetNav": {
"catalog": "Catalog",
"orders": "My orders",
"addresses": "My addresses",
"billing": "Balance",
"profile": "Profile",
"team": "Company",
"teamSettings": "Team settings",
"offers": "My offers",
"myOffers": "My offers",
"seller": "Seller",
"suppliers": "Suppliers",
"hubs": "Hubs",
"ai": "AI assistant"
}
}

View File

@@ -7,6 +7,13 @@
"error": "Error",
"success": "Success",
"retry": "Retry",
"back": "Back",
"list": "List",
"map": "Map",
"language": "Language",
"theme": "Theme",
"teams": "Teams",
"selectTeam": "Select team",
"actions": {
"load_more": "Load more"
},
@@ -19,5 +26,9 @@
"values": {
"not_available": "Not available"
}
},
"units": {
"t": "t",
"kg": "kg"
}
}

View File

@@ -3,10 +3,14 @@
"title": "Find Raw Materials",
"subtitle": "& Industrial Services",
"description": "Connect with suppliers, get competitive prices, and streamline your procurement process",
"product": "Product",
"product_label": "What are you looking for?",
"product_placeholder": "Select material type",
"quantity": "Quantity",
"quantity_label": "Quantity",
"quantity_placeholder": "Amount",
"destination": "Destination",
"destination_placeholder": "City or hub",
"location_label": "Delivery to",
"location_placeholder": "City or region",
"search_button": "Search",

View File

@@ -1,11 +1,17 @@
{
"cabinetNav": {
"catalog": "Каталог",
"orders": "Мои заказы",
"addresses": "Мои адреса",
"billing": "Баланс",
"profile": "Профиль",
"team": "Компания",
"teamSettings": "Настройки команды",
"offers": "Мои предложения",
"myOffers": "Мои предложения",
"seller": "Продавец",
"suppliers": "Поставщики",
"hubs": "Хабы",
"ai": "AI ассистент"
}
}

View File

@@ -7,6 +7,13 @@
"error": "Ошибка",
"success": "Успех",
"retry": "Повторить",
"back": "Назад",
"list": "Список",
"map": "Карта",
"language": "Язык",
"theme": "Тема",
"teams": "Команды",
"selectTeam": "Выбрать команду",
"actions": {
"load_more": "Показать еще"
},
@@ -19,5 +26,9 @@
"values": {
"not_available": "Нет данных"
}
},
"units": {
"t": "т",
"kg": "кг"
}
}

View File

@@ -3,10 +3,14 @@
"title": "Найти сырье",
"subtitle": "и промышленные сервисы",
"description": "Связывайтесь с поставщиками, получайте конкурентные цены и упрощайте процессы закупок",
"product": "Товар",
"product_label": "Что вы ищете?",
"product_placeholder": "Выберите тип материала",
"quantity": "Количество",
"quantity_label": "Количество",
"quantity_placeholder": "Объем",
"destination": "Куда",
"destination_placeholder": "Город или хаб",
"location_label": "Доставка в",
"location_placeholder": "Город или регион",
"search_button": "Найти",