Compare commits

..

6 Commits

24 changed files with 523 additions and 263 deletions

View File

@@ -19,7 +19,7 @@ ENV SENTRY_ENABLED=false
ENV NUXT_TELEMETRY_DISABLED=1 ENV NUXT_TELEMETRY_DISABLED=1
COPY . . COPY . .
RUN pnpm run build && pnpm prune --prod RUN pnpm run build && pnpm prune --prod --ignore-scripts
FROM node:22-slim AS runtime FROM node:22-slim AS runtime

View File

@@ -43,16 +43,36 @@ const LANDING_SEARCH_TOP_STOP = 16
const LANDING_SEARCH_BOTTOM_GAP = 30 const LANDING_SEARCH_BOTTOM_GAP = 30
const logisticsSearch = reactive({ const logisticsSearch = reactive({
from: '', destination: '',
to: '', product: '',
cargo: '', quantity: '',
}) })
function syncSearchFromRoute() { function syncSearchFromRoute() {
hydrateFromQuery(route.query) hydrateFromQuery(route.query)
logisticsSearch.from = typeof route.query.from === 'string' ? route.query.from : isCalcPage.value ? calcDraft.value.from : '' logisticsSearch.destination = typeof route.query.hubName === 'string'
logisticsSearch.to = typeof route.query.to === 'string' ? route.query.to : isCalcPage.value ? calcDraft.value.to : '' ? route.query.hubName
logisticsSearch.cargo = typeof route.query.cargo === 'string' ? route.query.cargo : isCalcPage.value ? calcDraft.value.cargo : '' : typeof route.query.to === 'string'
? route.query.to
: isCalcPage.value
? calcDraft.value.to
: ''
logisticsSearch.product = typeof route.query.productName === 'string'
? route.query.productName
: typeof route.query.from === 'string'
? route.query.from
: isCalcPage.value
? calcDraft.value.from
: ''
logisticsSearch.quantity = typeof route.query.qty === 'string'
? route.query.qty
: typeof route.query.quantity === 'string'
? route.query.quantity
: typeof route.query.cargo === 'string'
? route.query.cargo
: isCalcPage.value
? calcDraft.value.cargo
: ''
} }
function inferCountryIso(value: string, fallback: string) { function inferCountryIso(value: string, fallback: string) {
@@ -69,10 +89,8 @@ function isoToFlag(iso: string) {
return String.fromCodePoint(...[...normalized].map(char => 127397 + char.charCodeAt(0))) return String.fromCodePoint(...[...normalized].map(char => 127397 + char.charCodeAt(0)))
} }
const fromIso = computed(() => inferCountryIso(logisticsSearch.from, 'CN')) const destinationIso = computed(() => inferCountryIso(logisticsSearch.destination, 'RU'))
const toIso = computed(() => inferCountryIso(logisticsSearch.to, 'RU')) const destinationFlag = computed(() => isoToFlag(destinationIso.value))
const fromFlag = computed(() => isoToFlag(fromIso.value))
const toFlag = computed(() => isoToFlag(toIso.value))
const showAdminDock = computed(() => Boolean(showLogistics.value) && !isAuthPage.value) const showAdminDock = computed(() => Boolean(showLogistics.value) && !isAuthPage.value)
// Fullscreen menu // Fullscreen menu
const isMenuOpen = ref(false) const isMenuOpen = ref(false)
@@ -212,53 +230,60 @@ const headerBackdropClass = computed(() => {
return 'header-glass-backdrop--default' return 'header-glass-backdrop--default'
}) })
function buildHeaderSearchQuery(from: string, to: string, cargo: string) { function buildHeaderSearchQuery(destination: string, product: string, quantity: string) {
const currentQuery = route.query || {} const currentQuery = route.query || {}
const patch = { const patch = {
from, from: product,
to, to: destination,
cargo, cargo: quantity,
}
const semanticQuery = {
...(product ? { productName: product } : {}),
...(destination ? { hubName: destination } : {}),
...(quantity ? { qty: quantity, quantity } : {}),
} }
if (isCalcPage.value) { if (isCalcPage.value) {
return { return {
...currentQuery, ...currentQuery,
...buildCalcQuery(patch), ...buildCalcQuery(patch),
...semanticQuery,
} }
} }
return { return {
...currentQuery, ...currentQuery,
...(from ? { from } : {}), ...(product ? { from: product } : {}),
...(to ? { to } : {}), ...(destination ? { to: destination } : {}),
...(cargo ? { cargo } : {}), ...(quantity ? { cargo: quantity } : {}),
...semanticQuery,
} }
} }
async function submitHeaderSearch() { async function submitHeaderSearch() {
const from = logisticsSearch.from.trim() const destination = logisticsSearch.destination.trim()
const to = logisticsSearch.to.trim() const product = logisticsSearch.product.trim()
const cargo = logisticsSearch.cargo.trim() const quantity = logisticsSearch.quantity.trim()
await navigateToLocalized({ await navigateToLocalized({
path: '/catalog', path: '/catalog',
query: buildHeaderSearchQuery(from, to, cargo), query: buildHeaderSearchQuery(destination, product, quantity),
}) })
} }
type StepRoute = 'from' | 'to' | 'cargo' type StepRoute = 'destination' | 'product' | 'quantity'
async function openStep(step: StepRoute) { async function openStep(step: StepRoute) {
const from = logisticsSearch.from.trim() const destination = logisticsSearch.destination.trim()
const to = logisticsSearch.to.trim() const product = logisticsSearch.product.trim()
const cargo = logisticsSearch.cargo.trim() const quantity = logisticsSearch.quantity.trim()
const stepPath = step === 'from' const stepPath = step === 'destination'
? '/catalog/product'
: step === 'to'
? '/catalog/destination' ? '/catalog/destination'
: step === 'product'
? '/catalog/product'
: '/catalog/quantity' : '/catalog/quantity'
await navigateToLocalized({ await navigateToLocalized({
path: stepPath, path: stepPath,
query: buildHeaderSearchQuery(from, to, cargo), query: buildHeaderSearchQuery(destination, product, quantity),
}) })
} }
@@ -297,7 +322,7 @@ async function goToSignIn() {
<button <button
type="button" type="button"
class="btn btn-secondary h-10 min-h-0 w-full rounded-full text-sm font-semibold" class="btn btn-secondary h-10 min-h-0 w-full rounded-full text-sm font-semibold"
@click="openStep('from')" @click="openStep('destination')"
> >
{{ $t('ui.calculate') }} {{ $t('ui.calculate') }}
</button> </button>
@@ -321,43 +346,46 @@ async function goToSignIn() {
<div :class="searchCapsuleClass"> <div :class="searchCapsuleClass">
<form class="flex min-w-0 flex-wrap items-center gap-2 rounded-full" @submit.prevent="submitHeaderSearch"> <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"> <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">{{ fromFlag }}</span> <span class="shrink-0 text-base leading-none">{{ destinationFlag }}</span>
<input <input
:value="logisticsSearch.from" :value="logisticsSearch.destination"
type="text"
class="w-full cursor-pointer"
:placeholder="$t('ui.from')"
readonly
@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">{{ toFlag }}</span>
<input
:value="logisticsSearch.to"
type="text" type="text"
class="w-full cursor-pointer" class="w-full cursor-pointer"
:placeholder="$t('ui.to')" :placeholder="$t('ui.to')"
readonly readonly
@focus.prevent="openStep('to')" @focus.prevent="openStep('destination')"
@click.prevent="openStep('to')" @click.prevent="openStep('destination')"
/> />
</label> </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"> <label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <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="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="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" /> <path d="M12 22V12" />
</svg> </svg>
<input <input
:value="logisticsSearch.cargo" :value="logisticsSearch.product"
type="text" type="text"
class="w-full cursor-pointer" class="w-full cursor-pointer"
:placeholder="$t('ui.cargo')" :placeholder="$t('ui.product')"
readonly readonly
@focus.prevent="openStep('cargo')" @focus.prevent="openStep('product')"
@click.prevent="openStep('cargo')" @click.prevent="openStep('product')"
/>
</label>
<label class="search-arch input flex h-11 min-h-0 min-w-[170px] flex-1 items-center gap-3 rounded-full shadow-none">
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4 shrink-0 text-base-content/60" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a2 2 0 1 1 0-4h4a2 2 0 1 1 0 4h-4Zm0 0v2m4-2v2" />
<path d="M6 5h12M6 19h12" />
</svg>
<input
:value="logisticsSearch.quantity"
type="text"
class="w-full cursor-pointer"
:placeholder="$t('ui.quantity')"
readonly
@focus.prevent="openStep('quantity')"
@click.prevent="openStep('quantity')"
/> />
</label> </label>
<button type="submit" class="btn btn-secondary h-11 min-h-0 rounded-full px-5">{{ $t('ui.find') }}</button> <button type="submit" class="btn btn-secondary h-11 min-h-0 rounded-full px-5">{{ $t('ui.find') }}</button>

View File

@@ -1,10 +1,13 @@
export const useActiveTeam = () => { export const useActiveTeam = () => {
const activeTeamId = useState<string | null>('activeTeamId', () => null) const activeTeamId = useState<string | null>('activeTeamId', () => null)
const activeLogtoOrgId = useState<string | null>('activeLogtoOrgId', () => null) const activeLogtoOrgId = useState<string | null>('activeLogtoOrgId', () => null)
const logtoOrgState = useState<string | null>('logto-org-id', () => null)
const setActiveTeam = (teamId: string | null, logtoOrgId?: string | null) => { const setActiveTeam = (teamId: string | null, logtoOrgId?: string | null) => {
activeTeamId.value = teamId activeTeamId.value = teamId
activeLogtoOrgId.value = logtoOrgId ?? null const nextOrgId = logtoOrgId ?? null
activeLogtoOrgId.value = nextOrgId
logtoOrgState.value = nextOrgId
} }
return { activeTeamId, activeLogtoOrgId, setActiveTeam } return { activeTeamId, activeLogtoOrgId, setActiveTeam }

View File

@@ -7,15 +7,16 @@
export const useAuth = () => { export const useAuth = () => {
const { getToken, initTokens, idToken } = useLogtoTokens() const { getToken, initTokens, idToken } = useLogtoTokens()
const me = useState<{ id?: string | null } | null>('me', () => null) const me = useState<{ id?: string | null } | null>('me', () => null)
const isAuthenticated = computed(() => !!me.value?.id) const logtoUser = useState<Record<string, unknown> | null>('logto-user', () => null)
const isAuthenticated = computed(() => !!(me.value?.id || logtoUser.value))
const loggedIn = isAuthenticated const loggedIn = isAuthenticated
/** /**
* Get access token for a resource. * Get access token for a resource.
* Tokens are synced from SSR via useState, auto-refreshes if expired. * Tokens are synced from SSR via useState, auto-refreshes if expired.
*/ */
const getAccessToken = async (resource: string, _organizationId?: string): Promise<string> => { const getAccessToken = async (resource: string, organizationId?: string): Promise<string> => {
return getToken(resource as Parameters<typeof getToken>[0]) return getToken(resource as Parameters<typeof getToken>[0], organizationId)
} }
const getOrganizationToken = getAccessToken const getOrganizationToken = getAccessToken

View File

@@ -27,6 +27,7 @@ const CLIENT_MAP: Record<string, string> = {
export const useGraphQL = () => { export const useGraphQL = () => {
const auth = useAuth() const auth = useAuth()
const { activeLogtoOrgId } = useActiveTeam() const { activeLogtoOrgId } = useActiveTeam()
const { refreshTokens } = useLogtoTokens()
const getClientId = (endpoint: Endpoint, api: Api): string => { const getClientId = (endpoint: Endpoint, api: Api): string => {
return CLIENT_MAP[`${endpoint}:${api}`] || 'default' return CLIENT_MAP[`${endpoint}:${api}`] || 'default'
@@ -77,8 +78,8 @@ export const useGraphQL = () => {
): Promise<TResult> => { ): Promise<TResult> => {
const clientId = getClientId(endpoint, api) const clientId = getClientId(endpoint, api)
const { client } = useApolloClient(clientId) const { client } = useApolloClient(clientId)
const executeOnce = async () => {
const context = await getAuthContext(endpoint, api) const context = await getAuthContext(endpoint, api)
const result = await client.query({ const result = await client.query({
query: document, query: document,
variables, variables,
@@ -93,6 +94,23 @@ export const useGraphQL = () => {
return result.data as TResult return result.data as TResult
} }
try {
return await executeOnce()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const isAuthContextFailure = message.includes('Invalid Compact JWS')
|| message.includes('Context creation failed')
|| message.includes('Received status code 500')
if (endpoint === 'team' && isAuthContextFailure) {
await refreshTokens()
return await executeOnce()
}
throw error
}
}
const mutate = async <TResult, TVariables extends Record<string, unknown>>( const mutate = async <TResult, TVariables extends Record<string, unknown>>(
document: TypedDocumentNode<TResult, TVariables>, document: TypedDocumentNode<TResult, TVariables>,
variables: TVariables, variables: TVariables,
@@ -101,8 +119,8 @@ export const useGraphQL = () => {
): Promise<TResult> => { ): Promise<TResult> => {
const clientId = getClientId(endpoint, api) const clientId = getClientId(endpoint, api)
const { client } = useApolloClient(clientId) const { client } = useApolloClient(clientId)
const mutateOnce = async () => {
const context = await getAuthContext(endpoint, api) const context = await getAuthContext(endpoint, api)
const result = await client.mutate({ const result = await client.mutate({
mutation: document, mutation: document,
variables, variables,
@@ -116,5 +134,22 @@ export const useGraphQL = () => {
return result.data as TResult return result.data as TResult
} }
try {
return await mutateOnce()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const isAuthContextFailure = message.includes('Invalid Compact JWS')
|| message.includes('Context creation failed')
|| message.includes('Received status code 500')
if (endpoint === 'team' && isAuthContextFailure) {
await refreshTokens()
return await mutateOnce()
}
throw error
}
}
return { execute, mutate } return { execute, mutate }
} }

View File

@@ -27,14 +27,19 @@ const EXPIRY_BUFFER_MS = 60 * 1000
*/ */
export const useLogtoTokens = () => { export const useLogtoTokens = () => {
const tokens = useState<Partial<Record<ResourceKey, TokenInfo>>>('logto-tokens', () => ({})) const tokens = useState<Partial<Record<ResourceKey, TokenInfo>>>('logto-tokens', () => ({}))
const tokensOrgId = useState<string | null>('logto-tokens-org-id', () => null)
const idToken = useState<string | null>('logto-id-token', () => null) const idToken = useState<string | null>('logto-id-token', () => null)
const activeOrgId = useState<string | null>('activeLogtoOrgId', () => null)
const orgState = useState<string | null>('logto-org-id', () => null)
const isRefreshing = ref(false) const isRefreshing = ref(false)
let refreshPromise: Promise<void> | null = null let refreshPromise: Promise<void> | null = null
let refreshPromiseOrgId: string | null = null
/** /**
* Get organization ID from Logto user (first organization) * Get organization ID from Logto user (first organization)
*/ */
const getOrganizationId = (): string | undefined => { const resolveOrganizationId = (preferred?: string | null): string | undefined => {
if (preferred) return preferred
if (import.meta.server) { if (import.meta.server) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const context = nuxtApp.ssrContext?.event.context as { const context = nuxtApp.ssrContext?.event.context as {
@@ -43,8 +48,7 @@ export const useLogtoTokens = () => {
} | undefined } | undefined
return context?.logtoOrgId || context?.logtoUser?.organizations?.[0] return context?.logtoOrgId || context?.logtoUser?.organizations?.[0]
} }
const orgId = useState<string | null>('logto-org-id', () => null) return activeOrgId.value || orgState.value || undefined
return orgId.value || undefined
} }
/** /**
@@ -54,7 +58,7 @@ export const useLogtoTokens = () => {
if (import.meta.server) { if (import.meta.server) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const client = nuxtApp.ssrContext?.event.context.logtoClient const client = nuxtApp.ssrContext?.event.context.logtoClient
const organizationId = getOrganizationId() const organizationId = resolveOrganizationId()
if (client) { if (client) {
const results: Partial<Record<ResourceKey, TokenInfo>> = {} const results: Partial<Record<ResourceKey, TokenInfo>> = {}
@@ -78,6 +82,7 @@ export const useLogtoTokens = () => {
) )
tokens.value = results tokens.value = results
tokensOrgId.value = organizationId || null
// Also fetch ID token for SSR // Also fetch ID token for SSR
try { try {
@@ -124,24 +129,36 @@ export const useLogtoTokens = () => {
/** /**
* Refresh all tokens from server * Refresh all tokens from server
*/ */
const refreshTokens = async (): Promise<void> => { const refreshTokens = async (organizationId?: string | null): Promise<void> => {
const resolvedOrgId = resolveOrganizationId(organizationId) || null
// Deduplicate concurrent refresh calls // Deduplicate concurrent refresh calls
if (refreshPromise) { if (refreshPromise) {
if (refreshPromiseOrgId === resolvedOrgId) {
return refreshPromise return refreshPromise
} }
await refreshPromise
}
isRefreshing.value = true isRefreshing.value = true
refreshPromiseOrgId = resolvedOrgId
refreshPromise = (async () => { refreshPromise = (async () => {
try { try {
const response = await $fetch<RefreshResponse>('/api/auth/refresh', { const response = await $fetch<RefreshResponse>('/api/auth/refresh', {
method: 'POST' method: 'POST',
body: resolvedOrgId ? { organizationId: resolvedOrgId } : undefined
}) })
tokens.value = response.tokens tokens.value = response.tokens
tokensOrgId.value = resolvedOrgId
if (resolvedOrgId) {
orgState.value = resolvedOrgId
}
} }
finally { finally {
isRefreshing.value = false isRefreshing.value = false
refreshPromise = null refreshPromise = null
refreshPromiseOrgId = null
} }
})() })()
@@ -152,19 +169,21 @@ export const useLogtoTokens = () => {
* Get access token for a resource URL. * Get access token for a resource URL.
* Auto-refreshes if token is expired. * Auto-refreshes if token is expired.
*/ */
const getToken = async (resourceUrl: ResourceUrl): Promise<string> => { const getToken = async (resourceUrl: ResourceUrl, organizationId?: string | null): Promise<string> => {
// Find resource key by URL // Find resource key by URL
const entry = Object.entries(RESOURCES).find(([, url]) => url === resourceUrl) const entry = Object.entries(RESOURCES).find(([, url]) => url === resourceUrl)
if (!entry) { if (!entry) {
throw new Error(`Unknown resource: ${resourceUrl}`) throw new Error(`Unknown resource: ${resourceUrl}`)
} }
const key = entry[0] as ResourceKey const key = entry[0] as ResourceKey
const resolvedOrgId = resolveOrganizationId(organizationId) || null
const tokenInfo = tokens.value[key] const tokenInfo = tokens.value[key]
const isOrgMismatch = tokensOrgId.value !== resolvedOrgId
// If expired, refresh all tokens // If expired (or cached for another org), refresh all tokens
if (isTokenExpired(tokenInfo)) { if (isOrgMismatch || isTokenExpired(tokenInfo)) {
await refreshTokens() await refreshTokens(resolvedOrgId)
} }
const refreshedToken = tokens.value[key] const refreshedToken = tokens.value[key]
@@ -178,11 +197,13 @@ export const useLogtoTokens = () => {
/** /**
* Get token by resource key (teams, exchange, orders, kyc) * Get token by resource key (teams, exchange, orders, kyc)
*/ */
const getTokenByKey = async (key: ResourceKey): Promise<string> => { const getTokenByKey = async (key: ResourceKey, organizationId?: string | null): Promise<string> => {
const tokenInfo = tokens.value[key] const tokenInfo = tokens.value[key]
const resolvedOrgId = resolveOrganizationId(organizationId) || null
const isOrgMismatch = tokensOrgId.value !== resolvedOrgId
if (isTokenExpired(tokenInfo)) { if (isOrgMismatch || isTokenExpired(tokenInfo)) {
await refreshTokens() await refreshTokens(resolvedOrgId)
} }
const refreshedToken = tokens.value[key] const refreshedToken = tokens.value[key]

View File

@@ -12,8 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
} }
const { loggedIn } = useAuth() const { loggedIn } = useAuth()
const localePath = useLocalePath()
const logtoUser = useState<Record<string, unknown> | null>('logto-user', () => null)
if (!loggedIn.value) { if (!loggedIn.value && !logtoUser.value) {
return navigateTo('/sign-in') return navigateTo(localePath('/sign-in'))
} }
}) })

View File

@@ -15,5 +15,5 @@ definePageMeta({
const { t } = useI18n() const { t } = useI18n()
const localePath = useLocalePath() const localePath = useLocalePath()
await navigateTo(localePath('/')) await navigateTo(localePath('/clientarea/orders'))
</script> </script>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { locale } = useI18n() const { locale } = useI18n()
const localePath = useLocalePath()
const isEn = computed(() => locale.value === 'en') const isEn = computed(() => locale.value === 'en')
@@ -8,10 +7,6 @@ const heroTitle = computed(() => isEn.value
? 'Optovia makes procurement and logistics transparent' ? 'Optovia makes procurement and logistics transparent'
: 'Optovia делает закупку и логистику прозрачными') : 'Optovia делает закупку и логистику прозрачными')
const heroSubtitle = computed(() => isEn.value
? 'One flow for product search, hubs, offers, and route decisions.'
: 'Единый поток: поиск товара, хабы, офферы и решение по маршруту.')
const howSteps = computed(() => isEn.value const howSteps = computed(() => isEn.value
? [ ? [
{ {
@@ -53,36 +48,52 @@ const services = computed(() => isEn.value
{ {
title: 'Supplier and offer discovery', title: 'Supplier and offer discovery',
text: 'Find relevant suppliers and compare live commercial terms without tab chaos.', text: 'Find relevant suppliers and compare live commercial terms without tab chaos.',
toneFrom: '#0f243d',
toneTo: '#153962',
}, },
{ {
title: 'Hub-first route strategy', title: 'Hub-first route strategy',
text: 'Evaluate delivery through key hubs and optimize route economics early.', text: 'Evaluate delivery through key hubs and optimize route economics early.',
toneFrom: '#182b45',
toneTo: '#22466f',
}, },
{ {
title: 'Map-based operating control', title: 'Map-based operating control',
text: 'Keep product, destination, and route context in one place for faster execution.', text: 'Keep product, destination, and route context in one place for faster execution.',
toneFrom: '#15243a',
toneTo: '#214a60',
}, },
{ {
title: 'Team workflow continuity', title: 'Team workflow continuity',
text: 'Share context between buyer, operations, and manager roles without data loss.', text: 'Share context between buyer, operations, and manager roles without data loss.',
toneFrom: '#122033',
toneTo: '#1d3a5c',
}, },
] ]
: [ : [
{ {
title: 'Поиск поставщиков и офферов', title: 'Поиск поставщиков и офферов',
text: 'Находите релевантных поставщиков и сравнивайте коммерцию без хаоса вкладок.', text: 'Находите релевантных поставщиков и сравнивайте коммерцию без хаоса вкладок.',
toneFrom: '#0f243d',
toneTo: '#153962',
}, },
{ {
title: 'Маршрутная стратегия через хабы', title: 'Маршрутная стратегия через хабы',
text: 'Оценивайте доставку через ключевые хабы и заранее оптимизируйте экономику.', text: 'Оценивайте доставку через ключевые хабы и заранее оптимизируйте экономику.',
toneFrom: '#182b45',
toneTo: '#22466f',
}, },
{ {
title: 'Операционный контроль на карте', title: 'Операционный контроль на карте',
text: 'Держите товар, направление и маршрут в одном месте для быстрого исполнения.', text: 'Держите товар, направление и маршрут в одном месте для быстрого исполнения.',
toneFrom: '#15243a',
toneTo: '#214a60',
}, },
{ {
title: 'Непрерывный командный workflow', title: 'Непрерывный командный workflow',
text: 'Передавайте контекст между закупкой, операционкой и менеджментом без потерь.', text: 'Передавайте контекст между закупкой, операционкой и менеджментом без потерь.',
toneFrom: '#122033',
toneTo: '#1d3a5c',
}, },
]) ])
@@ -98,9 +109,16 @@ const advantages = computed(() => isEn.value
'Меньше операционных потерь в закупке и планировании маршрута.', 'Меньше операционных потерь в закупке и планировании маршрута.',
]) ])
const trustedBy = computed(() => isEn.value const trustedBy = [
? ['Agro Holdings', 'Food Retail', 'Import Teams', 'Distribution Groups', 'Regional Buyers', 'Logistics Partners'] { name: 'Gazprom', logo: '/trust-logos/gazprom.svg' },
: ['Агро холдинги', 'Пищевой ритейл', 'Импорт-команды', 'Дистрибьюторы', 'Региональные закупки', 'Логистические партнеры']) { name: 'Rossiya 1', logo: '/trust-logos/russia1.svg' },
{ name: 'Absolut Bank', logo: '/trust-logos/absolutbank.svg' },
{ name: 'Kalashnikov', logo: '/trust-logos/kalashnikov.svg' },
{ name: 'Sber Logistics', logo: '/trust-logos/sberlog.svg' },
{ name: 'Dellin', logo: '/trust-logos/dellin.svg' },
{ name: 'PEK', logo: '/trust-logos/pek.svg' },
{ name: 'FESCO', logo: '/trust-logos/fesco.svg' },
] as const
const testimonials = computed(() => isEn.value const testimonials = computed(() => isEn.value
? [ ? [
@@ -108,16 +126,19 @@ const testimonials = computed(() => isEn.value
quote: 'We reduced route decision time from days to hours because all options are visible on one map.', quote: 'We reduced route decision time from days to hours because all options are visible on one map.',
author: 'Elena Morozova', author: 'Elena Morozova',
role: 'Head of Procurement Operations', role: 'Head of Procurement Operations',
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
}, },
{ {
quote: 'The capsule search and hub view made supplier comparison much cleaner for our team.', quote: 'The capsule search and hub view made supplier comparison much cleaner for our team.',
author: 'Dmitry Volkov', author: 'Dmitry Volkov',
role: 'Import Manager', role: 'Import Manager',
avatar: 'https://randomuser.me/api/portraits/men/52.jpg',
}, },
{ {
quote: 'Optovia removed communication noise between buyers and logistics managers.', quote: 'Optovia removed communication noise between buyers and logistics managers.',
author: 'Alex Gromov', author: 'Alex Gromov',
role: 'CEO, Trading Company', role: 'CEO, Trading Company',
avatar: 'https://randomuser.me/api/portraits/men/41.jpg',
}, },
] ]
: [ : [
@@ -125,27 +146,22 @@ const testimonials = computed(() => isEn.value
quote: 'Скорость выбора маршрута сократилась с дней до часов, потому что все варианты видны на одной карте.', quote: 'Скорость выбора маршрута сократилась с дней до часов, потому что все варианты видны на одной карте.',
author: 'Екатерина Морозова', author: 'Екатерина Морозова',
role: 'Руководитель закупочной операционки', role: 'Руководитель закупочной операционки',
avatar: 'https://randomuser.me/api/portraits/women/44.jpg',
}, },
{ {
quote: 'Капсульный поиск и режим хабов сделали сравнение поставщиков заметно чище для команды.', quote: 'Капсульный поиск и режим хабов сделали сравнение поставщиков заметно чище для команды.',
author: 'Дмитрий Волков', author: 'Дмитрий Волков',
role: 'Менеджер по импорту', role: 'Менеджер по импорту',
avatar: 'https://randomuser.me/api/portraits/men/52.jpg',
}, },
{ {
quote: 'Optovia убрала шум в коммуникации между закупкой и логистикой.', quote: 'Optovia убрала шум в коммуникации между закупкой и логистикой.',
author: 'Александр Громов', author: 'Александр Громов',
role: 'CEO, торговая компания', role: 'CEO, торговая компания',
avatar: 'https://randomuser.me/api/portraits/men/41.jpg',
}, },
]) ])
const leadTestimonial = computed(() => testimonials.value[0] ?? null)
const sideTestimonials = computed(() => testimonials.value.slice(1))
const ctaTitle = computed(() => isEn.value ? 'Scale your procurement flow with Optovia' : 'Масштабируйте поток закупок вместе с Optovia')
const ctaText = computed(() => isEn.value
? 'Move from fragmented tools to one coherent workflow.'
: 'Перейдите от разрозненных инструментов к единому рабочему контуру.')
definePageMeta({ definePageMeta({
layout: 'topnav', layout: 'topnav',
}) })
@@ -153,19 +169,12 @@ definePageMeta({
<template> <template>
<main class="landing-page"> <main class="landing-page">
<section class="hero-section"> <section class="relative min-h-[72vh] w-full bg-gradient-to-br from-[#0b3a46] via-[#132b49] to-[#1a2a63] px-3 pb-10 pt-40 text-white md:px-4 md:pt-52">
<div class="mx-auto w-full max-w-[1280px] px-3 md:px-4"> <div class="mx-auto w-full max-w-[1280px]">
<div class="mx-auto max-w-[980px] text-center text-white"> <div class="mx-auto max-w-[940px] text-center" data-landing-search-anchor>
<h1 class="text-4xl font-black leading-tight md:text-6xl">{{ heroTitle }}</h1> <h1 class="text-4xl font-black leading-tight md:text-6xl">
<p class="mx-auto mt-5 max-w-[760px] text-base text-white/80 md:text-lg">{{ heroSubtitle }}</p> {{ heroTitle }}
<div class="mt-8 flex flex-wrap items-center justify-center gap-3"> </h1>
<NuxtLink :to="localePath('/catalog')" class="btn h-11 min-h-0 rounded-full border-0 bg-white px-6 text-[#12213a] hover:bg-white/90">
{{ isEn ? 'Open Catalog' : 'Открыть каталог' }}
</NuxtLink>
<NuxtLink :to="localePath('/catalog')" class="btn h-11 min-h-0 rounded-full border border-white/40 bg-transparent px-6 text-white hover:bg-white/10">
{{ isEn ? 'Explore map flow' : 'Посмотреть карту' }}
</NuxtLink>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -193,7 +202,12 @@ definePageMeta({
</header> </header>
<div class="service-stack"> <div class="service-stack">
<article v-for="(service, index) in services" :key="service.title" class="service-lane"> <article
v-for="(service, index) in services"
:key="service.title"
class="service-lane"
:style="{ backgroundImage: `linear-gradient(110deg, ${service.toneFrom} 0%, ${service.toneTo} 100%)` }"
>
<p class="service-index">{{ String(index + 1).padStart(2, '0') }}</p> <p class="service-index">{{ String(index + 1).padStart(2, '0') }}</p>
<div> <div>
<h3>{{ service.title }}</h3> <h3>{{ service.title }}</h3>
@@ -225,8 +239,10 @@ definePageMeta({
<h2>{{ isEn ? 'Trusted by teams' : 'Нам доверяют команды' }}</h2> <h2>{{ isEn ? 'Trusted by teams' : 'Нам доверяют команды' }}</h2>
</header> </header>
<div class="logo-wall" role="list"> <div class="logo-wall" role="list" :aria-label="isEn ? 'Client logos' : 'Логотипы клиентов'">
<div v-for="brand in trustedBy" :key="brand" role="listitem" class="logo-brand">{{ brand }}</div> <figure v-for="brand in trustedBy" :key="brand.name" role="listitem" class="logo-brand">
<img :src="brand.logo" :alt="`Logo ${brand.name}`" loading="lazy" />
</figure>
</div> </div>
</div> </div>
</section> </section>
@@ -238,44 +254,32 @@ definePageMeta({
</header> </header>
<div class="review-layout"> <div class="review-layout">
<article v-if="leadTestimonial" class="review-main"> <article class="review-main">
<p class="review-main__quote">«{{ leadTestimonial.quote }}»</p>
<div class="review-person"> <div class="review-person">
<div class="review-avatar review-avatar--lg">{{ leadTestimonial.author.slice(0, 1) }}</div> <img :src="testimonials[0].avatar" :alt="testimonials[0].author" class="review-avatar review-avatar--lg" loading="lazy" />
<div> <div>
<p class="review-name">{{ leadTestimonial.author }}</p> <p class="review-name">{{ testimonials[0].author }}</p>
<p class="review-role">{{ leadTestimonial.role }}</p> <p class="review-role">{{ testimonials[0].role }}</p>
</div> </div>
</div> </div>
<p class="review-main__quote">«{{ testimonials[0].quote }}»</p>
</article> </article>
<div class="review-side"> <div class="review-side">
<article v-for="item in sideTestimonials" :key="item.author" class="review-mini"> <article v-for="item in testimonials.slice(1)" :key="item.author" class="review-mini">
<p>«{{ item.quote }}»</p>
<div class="review-person"> <div class="review-person">
<div class="review-avatar">{{ item.author.slice(0, 1) }}</div> <img :src="item.avatar" :alt="item.author" class="review-avatar" loading="lazy" />
<div> <div>
<p class="review-name">{{ item.author }}</p> <p class="review-name">{{ item.author }}</p>
<p class="review-role">{{ item.role }}</p> <p class="review-role">{{ item.role }}</p>
</div> </div>
</div> </div>
<p>«{{ item.quote }}»</p>
</article> </article>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<section class="section section--cta">
<div class="section-inner">
<div class="cta-shell">
<h2>{{ ctaTitle }}</h2>
<p>{{ ctaText }}</p>
<NuxtLink :to="localePath('/catalog')" class="btn h-11 min-h-0 rounded-full border-0 bg-white px-6 text-[#12334f] hover:bg-white/90">
{{ isEn ? 'Start now' : 'Начать сейчас' }}
</NuxtLink>
</div>
</div>
</section>
</main> </main>
</template> </template>
@@ -288,14 +292,6 @@ definePageMeta({
background: #eef2f6; background: #eef2f6;
} }
.hero-section {
position: relative;
min-height: 72vh;
width: 100%;
background: linear-gradient(132deg, #0b3a46 0%, #132b49 48%, #1a2a63 100%);
padding: 10rem 0.75rem 2.5rem;
}
.section { .section {
padding: 3.25rem 0; padding: 3.25rem 0;
} }
@@ -364,7 +360,9 @@ definePageMeta({
} }
.section--dark { .section--dark {
background: linear-gradient(155deg, #0b1a2f 0%, #102842 100%); background:
radial-gradient(circle at 90% 15%, rgba(217, 61, 67, 0.3), rgba(217, 61, 67, 0) 34%),
linear-gradient(155deg, #0b1a2f 0%, #102842 100%);
} }
.service-stack { .service-stack {
@@ -381,7 +379,6 @@ definePageMeta({
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
align-items: start; align-items: start;
color: #fff; color: #fff;
background: linear-gradient(110deg, #10243f 0%, #1c4665 100%);
} }
.service-index { .service-index {
@@ -403,7 +400,9 @@ definePageMeta({
} }
.section--accent { .section--accent {
background: linear-gradient(180deg, #f8f0ea 0%, #f4ece8 100%); background:
radial-gradient(circle at 6% 0%, rgba(251, 220, 207, 0.54), rgba(251, 220, 207, 0) 37%),
linear-gradient(180deg, #f8f0ea 0%, #f4ece8 100%);
} }
.why-grid { .why-grid {
@@ -426,14 +425,39 @@ definePageMeta({
.why-list { .why-list {
margin: 0; margin: 0;
padding-left: 1.2rem; padding: 0;
list-style: none;
display: grid; display: grid;
gap: 0.75rem; gap: 1rem;
color: #243e5c; }
.why-list li {
padding: 0.2rem 0 0.2rem 2.3rem;
color: #1d2f49;
font-weight: 700;
position: relative;
line-height: 1.45;
}
.why-list li::before {
content: '✓';
position: absolute;
left: 0;
top: 0.05rem;
width: 1.55rem;
height: 1.55rem;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: #1f8a5a;
color: #fff;
font-size: 0.9rem;
font-weight: 900;
} }
.section--plain { .section--plain {
background: #f1f4f8; background: #f7f9fc;
} }
.logo-wall { .logo-wall {
@@ -443,42 +467,83 @@ definePageMeta({
} }
.logo-brand { .logo-brand {
border-radius: 12px; margin: 0;
border: 1px solid #d3deea; min-height: 84px;
background: #fff; border-radius: 20px;
padding: 0.9rem; border: 1px solid #d4dde8;
text-align: center; background: linear-gradient(180deg, #ffffff 0%, #f2f6fb 100%);
font-weight: 700; display: flex;
color: #2d4561; align-items: center;
justify-content: center;
padding: 0.75rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.logo-brand img {
width: min(88%, 240px);
height: auto;
object-fit: contain;
} }
.section--reviews { .section--reviews {
background: #0f1f34; background: linear-gradient(180deg, #edf3fb 0%, #e9f0f8 100%);
} flex: 1 0 auto;
padding-bottom: 0;
.section--reviews .section-header h2 { margin-bottom: 0;
color: #fff;
} }
.review-layout { .review-layout {
display: grid; display: grid;
gap: 1rem; gap: 0.8rem;
} }
.review-main, .review-main {
.review-mini { border-radius: 24px;
border-radius: 18px; padding: 1.3rem;
border: 1px solid rgba(255, 255, 255, 0.15); background: linear-gradient(145deg, #14253d 0%, #1c3b5e 100%);
background: rgba(255, 255, 255, 0.08); color: #fff;
padding: 1.2rem; display: grid;
color: rgba(255, 255, 255, 0.9); align-content: start;
gap: 0.9rem;
}
.review-person {
display: flex;
align-items: center;
gap: 0.75rem;
}
.review-avatar {
width: 2.75rem;
height: 2.75rem;
border-radius: 999px;
object-fit: cover;
border: 2px solid rgba(255, 255, 255, 0.44);
}
.review-avatar--lg {
width: 3.2rem;
height: 3.2rem;
}
.review-name {
margin: 0;
font-size: 0.95rem;
font-weight: 800;
color: #ffffff;
}
.review-role {
margin: 0.1rem 0 0;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.82);
} }
.review-main__quote { .review-main__quote {
margin: 0; margin: 0;
font-size: 1.18rem; color: #fff;
line-height: 1.55; font-size: clamp(1.2rem, 2.6vw, 1.6rem);
font-weight: 700; line-height: 1.45;
} }
.review-side { .review-side {
@@ -486,87 +551,76 @@ definePageMeta({
gap: 0.8rem; gap: 0.8rem;
} }
.review-person { .review-mini {
margin-top: 1rem; border-left: 4px solid #d12e35;
display: flex; padding: 0.75rem 0.9rem;
align-items: center; background: rgba(255, 255, 255, 0.65);
gap: 0.8rem;
} }
.review-avatar { .review-mini > p {
height: 2.25rem; margin: 0.55rem 0 0;
width: 2.25rem; color: #27405f;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
background: rgba(255, 255, 255, 0.18);
} }
.review-avatar--lg { .review-mini .review-name {
height: 2.75rem; color: #1e3555;
width: 2.75rem;
} }
.review-name { .review-mini .review-role {
margin: 0; color: #4f6581;
font-weight: 700;
color: #fff;
}
.review-role {
margin: 0.2rem 0 0;
font-size: 0.86rem;
color: rgba(255, 255, 255, 0.65);
}
.section--cta {
background: #eef2f6;
}
.cta-shell {
border-radius: 24px;
padding: 2rem;
background: linear-gradient(120deg, #0f3b54 0%, #1f5b7f 100%);
color: #fff;
text-align: center;
}
.cta-shell h2 {
margin: 0;
font-size: clamp(1.8rem, 4vw, 2.8rem);
font-weight: 900;
}
.cta-shell p {
margin: 0.8rem auto 1.4rem;
max-width: 680px;
color: rgba(255, 255, 255, 0.82);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.hero-section { .section {
padding-top: 12rem; padding: 4.5rem 0;
} }
.review-layout { .section-inner {
grid-template-columns: 1.1fr 0.9fr; padding: 0 1rem;
} }
.logo-wall { .steps-flow {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.5rem;
}
.step-item {
border-top: 0;
border-left: 1px solid #cfdae8;
padding: 1rem 0 1rem 1.7rem;
}
.step-number {
position: static;
margin-bottom: 0.75rem;
} }
.why-grid { .why-grid {
grid-template-columns: 1fr 1fr; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
align-items: start; align-items: start;
} }
.why-list {
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: stretch;
gap: 1.35rem;
} }
@media (min-width: 1024px) {
.logo-wall { .logo-wall {
grid-template-columns: repeat(6, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem 1.2rem;
}
.review-layout {
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 1.2rem;
}
}
@media (max-width: 1023px) {
.landing-page {
padding-bottom: 0;
} }
} }
</style> </style>

View File

@@ -1,20 +1,48 @@
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
const originalConsoleError = console.error const originalConsoleError = console.error
const originalConsoleWarn = console.warn
console.error = (...args: unknown[]) => { const shouldSuppressApolloNoise = (args: unknown[]) => {
const hasApolloDevtoolsWarning = args.some((arg) => { const serializedArgs = args
if (typeof arg !== 'string') return false .map((arg) => {
if (typeof arg === 'string') return arg
if (arg instanceof Error) return `${arg.message}\n${arg.stack || ''}`
try {
return JSON.stringify(arg)
} catch {
return String(arg)
}
})
.join(' ')
return ( return (
arg.includes('connectToDevTools') && (
arg.includes('devtools.enabled') serializedArgs.includes('connectToDevTools')
&& serializedArgs.includes('devtools.enabled')
) )
}) || (
serializedArgs.includes('go.apollo.dev/c/err')
&& (
serializedArgs.includes('"message":104')
|| serializedArgs.includes('%22message%22%3A104')
)
)
)
}
if (hasApolloDevtoolsWarning) { console.error = (...args: unknown[]) => {
if (shouldSuppressApolloNoise(args)) {
return return
} }
originalConsoleError(...args) originalConsoleError(...args)
} }
console.warn = (...args: unknown[]) => {
if (shouldSuppressApolloNoise(args)) {
return
}
originalConsoleWarn(...args)
}
}) })

View File

@@ -9,6 +9,8 @@
"from": "From", "from": "From",
"to": "To", "to": "To",
"cargo": "Cargo", "cargo": "Cargo",
"product": "What",
"quantity": "How much",
"find": "Find", "find": "Find",
"manager_navigation": "Manager navigation", "manager_navigation": "Manager navigation",
"orders": "Orders", "orders": "Orders",

View File

@@ -9,6 +9,8 @@
"from": "Откуда", "from": "Откуда",
"to": "Куда", "to": "Куда",
"cargo": "Груз", "cargo": "Груз",
"product": "Что",
"quantity": "Сколько",
"find": "Найти", "find": "Найти",
"manager_navigation": "Навигация менеджера", "manager_navigation": "Навигация менеджера",
"orders": "Заказы", "orders": "Заказы",

View File

@@ -188,6 +188,20 @@ export default defineNuxtConfig({
} }
} }
}, },
routeRules: {
// Avoid stale HTML after deploys (old page -> missing _nuxt chunks -> white flash).
'/**': {
headers: {
'cache-control': 'no-store'
}
},
// Keep long-lived immutable cache for hashed static assets.
'/_nuxt/**': {
headers: {
'cache-control': 'public, max-age=31536000, immutable'
}
}
},
devServer: { devServer: {
host: '0.0.0.0', host: '0.0.0.0',
port: 3000 port: 3000

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">Absolut Bank</title>
<rect width="180" height="64" rx="18" fill="#fff8ea"/>
<circle cx="34" cy="32" r="14" fill="#d84a2f"/>
<text x="58" y="37" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="18" font-weight="800">Absolut Bank</text>
</svg>

After

Width:  |  Height:  |  Size: 405 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">Dellin</title>
<rect width="180" height="64" rx="18" fill="#eef8f0"/>
<path d="M28 20h22v24H28z" fill="#2b8a3e"/>
<path d="M34 26h10v12H34z" fill="#ffffff"/>
<text x="62" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="800">Dellin</text>
</svg>

After

Width:  |  Height:  |  Size: 435 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">FESCO</title>
<rect width="180" height="64" rx="18" fill="#eef5ff"/>
<path d="M24 39c13-17 29-17 42 0" fill="none" stroke="#2563eb" stroke-width="7" stroke-linecap="round"/>
<text x="76" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="23" font-weight="900">FESCO</text>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">Gazprom</title>
<rect width="180" height="64" rx="18" fill="#eef7ff"/>
<path d="M36 18c8 10 2 15 2 22 0 5 4 8 9 8 8 0 13-6 13-14 0-9-7-16-15-22 2 10-8 14-9 6Z" fill="#2b7de9"/>
<text x="72" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="900">Gazprom</text>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">Kalashnikov</title>
<rect width="180" height="64" rx="18" fill="#f5f0ea"/>
<path d="M30 20h24v8H42v16H30z" fill="#242424"/>
<text x="64" y="37" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="17" font-weight="900">Kalashnikov</text>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">PEK</title>
<rect width="180" height="64" rx="18" fill="#fff2e6"/>
<rect x="26" y="20" width="34" height="24" rx="7" fill="#ef7d22"/>
<text x="74" y="39" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="27" font-weight="900">PEK</text>
</svg>

After

Width:  |  Height:  |  Size: 406 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">Russia 1</title>
<rect width="180" height="64" rx="18" fill="#eef3ff"/>
<rect x="24" y="20" width="42" height="24" rx="7" fill="#2563eb"/>
<rect x="50" y="20" width="16" height="24" rx="5" fill="#dc2626"/>
<text x="78" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="900">Russia 1</text>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="64" viewBox="0 0 180 64" role="img" aria-labelledby="title">
<title id="title">SberLog</title>
<rect width="180" height="64" rx="18" fill="#edf9f2"/>
<circle cx="39" cy="32" r="15" fill="#21a038"/>
<path d="m31 31 6 6 13-15" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<text x="66" y="38" fill="#1f2937" font-family="Onest, Arial, sans-serif" font-size="22" font-weight="900">SberLog</text>
</svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@@ -1,4 +1,4 @@
import { defineEventHandler, createError } from 'h3' import { defineEventHandler, createError, readBody } from 'h3'
import type LogtoClient from '@logto/node' import type LogtoClient from '@logto/node'
const RESOURCES = { const RESOURCES = {
@@ -20,6 +20,10 @@ export interface RefreshResponse {
tokens: Partial<Record<ResourceKey, TokenInfo>> tokens: Partial<Record<ResourceKey, TokenInfo>>
} }
interface RefreshBody {
organizationId?: string
}
function decodeTokenExpiry(token: string): number { function decodeTokenExpiry(token: string): number {
try { try {
const payload = token.split('.')[1] const payload = token.split('.')[1]
@@ -44,13 +48,20 @@ function decodeTokenExpiry(token: string): number {
export default defineEventHandler(async (event): Promise<RefreshResponse> => { export default defineEventHandler(async (event): Promise<RefreshResponse> => {
const client = event.context.logtoClient as LogtoClient | undefined const client = event.context.logtoClient as LogtoClient | undefined
const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined
let body: RefreshBody = {}
try {
body = (await readBody<RefreshBody>(event)) || {}
}
catch {
body = {}
}
if (!client) { if (!client) {
throw createError({ statusCode: 401, message: 'Not authenticated' }) throw createError({ statusCode: 401, message: 'Not authenticated' })
} }
// Get first organization from Logto user // Prefer explicit organizationId from client when switching active team.
const organizationId = logtoUser?.organizations?.[0] const organizationId = body.organizationId || logtoUser?.organizations?.[0]
const tokens: Partial<Record<ResourceKey, TokenInfo>> = {} const tokens: Partial<Record<ResourceKey, TokenInfo>> = {}

View File

@@ -154,7 +154,8 @@ export default defineEventHandler(async (event) => {
if (normalizedPath === '/callback') { if (normalizedPath === '/callback') {
await logto.handleSignInCallback(url.href) await logto.handleSignInCallback(url.href)
await sendRedirect(event, localePrefix || '/', 302) const clientareaPath = `${localePrefix}/clientarea`
await sendRedirect(event, clientareaPath || '/clientarea', 302)
return return
} }

View File

@@ -32,14 +32,21 @@ export default defineEventHandler(async (event) => {
const client = event.context.logtoClient as LogtoClient | undefined const client = event.context.logtoClient as LogtoClient | undefined
if (!client) return if (!client) return
let idToken: string | null = null const logtoUser = event.context.logtoUser as { organizations?: string[] } | undefined
const organizationId = event.context.logtoOrgId || logtoUser?.organizations?.[0]
let token: string | null = null
try { try {
idToken = await client.getIdToken() token = await client.getIdToken()
} catch {
try {
token = await client.getAccessToken('https://teams.optovia.ru', organizationId)
} catch { } catch {
return return
} }
}
if (!idToken) return if (!token) return
try { try {
const { GetMeDocument, GetMeProfileDocument } = await import('~/composables/graphql/user/teams-generated') const { GetMeDocument, GetMeProfileDocument } = await import('~/composables/graphql/user/teams-generated')
@@ -48,12 +55,12 @@ export default defineEventHandler(async (event) => {
const [meResponse, profileResponse] = await Promise.all([ const [meResponse, profileResponse] = await Promise.all([
$fetch<{ data?: { me?: MePayload } }>(endpoint, { $fetch<{ data?: { me?: MePayload } }>(endpoint, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${idToken}` }, headers: { Authorization: `Bearer ${token}` },
body: { query: print(GetMeDocument) } body: { query: print(GetMeDocument) }
}), }),
$fetch<{ data?: { me?: MePayload } }>(endpoint, { $fetch<{ data?: { me?: MePayload } }>(endpoint, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${idToken}` }, headers: { Authorization: `Bearer ${token}` },
body: { query: print(GetMeProfileDocument) } body: { query: print(GetMeProfileDocument) }
}) })
]) ])