Compare commits
6 Commits
84deb2d1bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39712613ae | ||
|
|
e1e6993f35 | ||
|
|
7b4eaeeb92 | ||
|
|
351125b51d | ||
|
|
7033df0fbc | ||
|
|
008f41d891 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"from": "Откуда",
|
"from": "Откуда",
|
||||||
"to": "Куда",
|
"to": "Куда",
|
||||||
"cargo": "Груз",
|
"cargo": "Груз",
|
||||||
|
"product": "Что",
|
||||||
|
"quantity": "Сколько",
|
||||||
"find": "Найти",
|
"find": "Найти",
|
||||||
"manager_navigation": "Навигация менеджера",
|
"manager_navigation": "Навигация менеджера",
|
||||||
"orders": "Заказы",
|
"orders": "Заказы",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
6
public/trust-logos/absolutbank.svg
Normal 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 |
7
public/trust-logos/dellin.svg
Normal 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 |
6
public/trust-logos/fesco.svg
Normal 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 |
6
public/trust-logos/gazprom.svg
Normal 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 |
6
public/trust-logos/kalashnikov.svg
Normal 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 |
6
public/trust-logos/pek.svg
Normal 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 |
7
public/trust-logos/russia1.svg
Normal 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 |
7
public/trust-logos/sberlog.svg
Normal 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 |
@@ -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>> = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) }
|
||||||
})
|
})
|
||||||
])
|
])
|
||||||
|
|||||||