diff --git a/components/entities/manager/ManagerEntityLink.vue b/components/entities/manager/ManagerEntityLink.vue
new file mode 100644
index 000000000..06fe48872
--- /dev/null
+++ b/components/entities/manager/ManagerEntityLink.vue
@@ -0,0 +1,96 @@
+
+
+
+
+
+ {{ displayName }}
+ {{ displayName }}
+ {{ displayName }}
+
+
diff --git a/components/entities/vault/SecuritizeVaultItem.vue b/components/entities/vault/SecuritizeVaultItem.vue
index a5300b8b0..566c90fc2 100644
--- a/components/entities/vault/SecuritizeVaultItem.vue
+++ b/components/entities/vault/SecuritizeVaultItem.vue
@@ -4,7 +4,6 @@ import { formatAssetValue } from '~/utils/sdk-prices'
import { isVaultBlockedByCountry } from '~/composables/useGeoBlock'
import { useEulerProductOfVault, useEulerEntitiesOfVault } from '~/composables/useEulerLabels'
import { isVaultGovernanceLimited } from '~/utils/eulerLabelsUtils'
-import { getEulerLabelEntityLogo } from '~/entities/euler/labels'
import { withVaultIntrinsicApy, getVaultIntrinsicApy, getVaultIntrinsicApyInfo } from '~/utils/vault-intrinsic-apy'
import { formatNumber, formatCompactUsdValue } from '~/utils/string-utils'
import { VaultApyModal, UiModalPreviewTrigger } from '#components'
@@ -30,10 +29,6 @@ const entityName = computed(() => {
if (entities.length === 2) return `${entities[0].name} & ${entities[1].name}`
return `${entities[0].name} & others`
})
-const entityLogos = computed(() => {
- if (!entityName.value || entities.length === 0) return []
- return entities.map(e => getEulerLabelEntityLogo(e.logo))
-})
const displayName = computed(() => product.name || vault.shares.name)
const isGeoBlocked = computed(() => isVaultBlockedByCountry(vault.address))
@@ -198,18 +193,14 @@ watchEffect(async () => {
class="flex items-center gap-6"
:class="{ 'opacity-20': isGovernanceLimited }"
>
-
- {{ entityName }}
+ />
{
class="flex items-center gap-8"
:class="{ 'opacity-20': isGovernanceLimited }"
>
-
- {{ entityName }}
{
const entityDisplay = computed(() => {
const all = getUniqueEntitiesByVaults([pair.collateral, pair.borrow])
- if (all.length === 0) return { name: '', logos: [] }
+ if (all.length === 0) return { name: '', entities: [] }
const name = all.length === 1
? all[0].name
: all.length === 2
@@ -39,7 +38,7 @@ const entityDisplay = computed(() => {
: `${all[0].name} & others`
return {
name,
- logos: all.map(e => getEulerLabelEntityLogo(e.logo)),
+ entities: all,
}
})
@@ -465,18 +464,14 @@ const linkPath = computed(() => ({
class="flex items-center gap-6"
:class="{ 'opacity-20': isAnyGovernanceLimited }"
>
-
- {{ entityDisplay.name }}
+ />
({
class="flex items-center gap-8"
:class="{ 'opacity-20': isAnyGovernanceLimited }"
>
-
- {{ entityDisplay.name }}
{
if (entities.length === 2) return `${entities[0].name} & ${entities[1].name}`
return `${entities[0].name} & others`
})
-const entityLogos = computed(() => {
- if (!entityName.value || entities.length === 0) return []
- return entities.map(e => getEulerLabelEntityLogo(e.logo))
-})
const { getBalance, isLoading: isBalancesLoading } = useWallets()
const { settings } = useUserSettings()
const enableIntrinsicApy = computed(() => settings.value.enableIntrinsicApy)
@@ -327,18 +322,14 @@ const supplyApyModalData = computed(() => ({
v-else-if="entityName"
class="flex items-center gap-6"
>
-
- {{ entityName }}
+ />
({
/>
Unknown
-
-
- {{ entityName }}
-
+
{
if (entities.length === 2) return `${entities[0].name} & ${entities[1].name}`
return `${entities[0].name} & others`
})
-const entityLogos = computed(() => {
- if (!entityName.value || entities.length === 0) return []
- return entities.map(e => getEulerLabelEntityLogo(e.logo))
-})
const isEscrow = computed(() => getVaultCategory(vault.address) === 'escrow')
const isBorrowable = computed(() => isVaultBorrowable(vault))
const displayName = computed(() => {
@@ -299,18 +294,14 @@ watchEffect(async () => {
class="flex items-center gap-6"
:class="{ 'opacity-20': isGovernanceLimited }"
>
-
- {{ entityName }}
+ />
{
class="flex items-center gap-8"
:class="{ 'opacity-20': isGovernanceLimited }"
>
-
- {{ entityName }}
{
class="flex items-center gap-8"
:class="{ 'opacity-20': isGovernanceLimited }"
>
-
-
{{ entity.name }}
{
class="flex items-center gap-8"
:class="{ 'opacity-20': isGovernanceLimited }"
>
-
- {{ entity.name }}
- {{ entity.name }}
diff --git a/components/entities/vault/overview/earn/VaultOverviewEarnBlockGeneral.vue b/components/entities/vault/overview/earn/VaultOverviewEarnBlockGeneral.vue
index 9d1434ce0..06aa14552 100644
--- a/components/entities/vault/overview/earn/VaultOverviewEarnBlockGeneral.vue
+++ b/components/entities/vault/overview/earn/VaultOverviewEarnBlockGeneral.vue
@@ -4,7 +4,6 @@ import { getAddress } from 'viem'
import { formatAssetValue } from '~/utils/sdk-prices'
import { useEulerEntitiesOfEarnVault, useEulerProductOfVault } from '~/composables/useEulerLabels'
-import { getEulerLabelEntityLogo } from '~/entities/euler/labels'
import { isVaultBlockedByCountry } from '~/composables/useGeoBlock'
import { isEarnVaultDeprecated, getEarnVaultDeprecationReason, getEarnVaultDescription } from '~/utils/eulerLabelsUtils'
import { autoLink } from '~/utils/autoLink'
@@ -103,21 +102,7 @@ const feeDisplay = computed(() => {
:key="idx"
class="flex items-center gap-8"
>
-
-
{{ entity.name }}
-
{{ entity.name }}
+
) => {
+ const { entities, products, isReady: labelsReady } = useEulerLabels()
+ const { isEarnUpdating } = useVaults()
+ const { getEarnVaults } = useVaultRegistry()
+ const {
+ marketGroups,
+ isReady: marketGroupsReady,
+ isResolvingTVL,
+ } = useMarketGroups()
+
+ const entity = computed(() => entities[slug.value] ?? null)
+
+ const productEntries = computed(() =>
+ Object.entries(products)
+ .filter(([, product]) => isEulerLabelProductManagedBy(product, slug.value))
+ .map(([key, product]) => ({ key, product }))
+ .sort((a, b) => a.product.name.localeCompare(b.product.name)),
+ )
+
+ const managedProductKeys = computed(() => new Set(productEntries.value.map(entry => entry.key)))
+
+ const managedMarkets = computed(() =>
+ marketGroups.value
+ .filter(group => group.source === 'product' && managedProductKeys.value.has(group.id))
+ .sort((a, b) => b.metrics.totalTVL - a.metrics.totalTVL || a.name.localeCompare(b.name)),
+ )
+
+ const managesEarnEntity = (candidate: EulerEarn): boolean =>
+ getEntitiesByEarnVault(candidate).some(manager =>
+ getEulerLabelEntitySlug(entities, manager) === slug.value,
+ )
+
+ const earnVaults = computed(() =>
+ getEarnVaults()
+ .filter(managesEarnEntity)
+ .sort((a, b) => a.asset.symbol.localeCompare(b.asset.symbol)),
+ )
+
+ const isLoading = computed(() =>
+ !labelsReady.value
+ || !marketGroupsReady.value
+ || isResolvingTVL.value
+ || isEarnUpdating.value,
+ )
+
+ return {
+ entity,
+ productEntries,
+ managedMarkets,
+ earnVaults,
+ isLoading,
+ }
+}
diff --git a/entities/euler/labels.ts b/entities/euler/labels.ts
index c52312b4c..54954f19b 100644
--- a/entities/euler/labels.ts
+++ b/entities/euler/labels.ts
@@ -4,13 +4,7 @@ export type EulerLabelEntity = {
description: string
url: string
addresses: Record
- social: {
- twitter: string
- youtube: string
- discord: string
- telegram: string
- github: string
- }
+ social: Record
}
export type EulerLabelVaultOverride = {
name?: string
@@ -30,6 +24,7 @@ export type EulerLabelProduct = {
portfolioNotice?: string
entity: string[] | string
url: string
+ logo?: string
vaults: string[]
deprecatedVaults?: string[]
deprecationReason?: string
diff --git a/pages/managers/[slug].vue b/pages/managers/[slug].vue
new file mode 100644
index 000000000..496c39fe4
--- /dev/null
+++ b/pages/managers/[slug].vue
@@ -0,0 +1,141 @@
+
+
+
+
+
+
+
+
+
+
+
+ Manager not found on this network.
+
+
+ Browse all markets
+
+
+
+
+
+
+
+
+
+
+
+ {{ entity.name }}
+
+
+
+
+
+ No profile description is available yet.
+
+
+
+
+
+
+
+
+
+ Markets
+
+ {{ managedMarkets.length }}
+
+
+
+
+
+
+
+
+ Earn vaults
+
+ {{ earnVaults.length }}
+
+
+
+
+
+
+
diff --git a/tests/utils/manager-profile.test.ts b/tests/utils/manager-profile.test.ts
new file mode 100644
index 000000000..de4ea86f5
--- /dev/null
+++ b/tests/utils/manager-profile.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest'
+import type { EulerLabelEntity, EulerLabelProduct } from '~/entities/euler/labels'
+import {
+ getEulerLabelEntityDisplayName,
+ getEulerLabelEntityKeys,
+ getEulerLabelEntitySlug,
+ getManagerProfileExternalUrl,
+ getManagerProfilePath,
+ getManagerProfileSocialLinks,
+ getManagerProfileSocialUrl,
+ isEulerLabelProductManagedBy,
+} from '~/utils/manager-profile'
+
+const entity = (name: string, logo = `${name}.svg`): EulerLabelEntity => ({
+ name,
+ logo,
+ description: '',
+ url: `https://${name.toLowerCase()}.example`,
+ addresses: {},
+ social: {
+ twitter: '',
+ youtube: '',
+ discord: '',
+ telegram: '',
+ github: '',
+ },
+})
+
+describe('manager profile helpers', () => {
+ it('normalizes product entity keys', () => {
+ expect(getEulerLabelEntityKeys({ entity: 'k3' } as EulerLabelProduct)).toEqual(['k3'])
+ expect(getEulerLabelEntityKeys({ entity: ['k3', 're7'] } as EulerLabelProduct)).toEqual(['k3', 're7'])
+ expect(getEulerLabelEntityKeys({ entity: '' } as EulerLabelProduct)).toEqual([])
+ })
+
+ it('detects products managed by a slug', () => {
+ const product = { entity: ['k3', 're7'] } as EulerLabelProduct
+
+ expect(isEulerLabelProductManagedBy(product, 're7')).toBe(true)
+ expect(isEulerLabelProductManagedBy(product, 'mev-capital')).toBe(false)
+ })
+
+ it('resolves an entity slug by reference or stable fields', () => {
+ const k3 = entity('K3')
+ const entities = { k3, re7: entity('Re7') }
+
+ expect(getEulerLabelEntitySlug(entities, k3)).toBe('k3')
+ expect(getEulerLabelEntitySlug(entities, { ...k3 })).toBe('k3')
+ })
+
+ it('formats compact manager labels', () => {
+ expect(getEulerLabelEntityDisplayName([])).toBe('')
+ expect(getEulerLabelEntityDisplayName([entity('K3')])).toBe('K3')
+ expect(getEulerLabelEntityDisplayName([entity('K3'), entity('Re7')])).toBe('K3 & Re7')
+ expect(getEulerLabelEntityDisplayName([entity('K3'), entity('Re7'), entity('MEV Capital')])).toBe('K3 & others')
+ })
+
+ it('builds manager profile paths', () => {
+ expect(getManagerProfilePath('mev-capital')).toBe('/managers/mev-capital')
+ })
+
+ it('normalizes manager social handles into external URLs', () => {
+ expect(getManagerProfileSocialUrl('twitter', '@k3_capital')).toBe('https://x.com/k3_capital')
+ expect(getManagerProfileSocialUrl('github', 'euler-xyz')).toBe('https://github.com/euler-xyz')
+ expect(getManagerProfileSocialUrl('telegram', 'https://t.me/UsualCommunity')).toBe('https://t.me/UsualCommunity')
+ expect(getManagerProfileSocialUrl('discord', 'hhttps://discord.usual.money/')).toBe('https://discord.usual.money/')
+ expect(getManagerProfileExternalUrl('example.com')).toBe('https://example.com')
+ })
+
+ it('builds social links from entity metadata', () => {
+ expect(getManagerProfileSocialLinks({
+ ...entity('K3'),
+ url: 'k3.capital',
+ social: {
+ twitter: 'k3_capital',
+ youtube: '',
+ discord: '',
+ telegram: '',
+ github: 'k3-capital',
+ legal: 'legal.k3.capital',
+ },
+ })).toEqual([
+ { label: 'Website', url: 'https://k3.capital' },
+ { label: 'X', url: 'https://x.com/k3_capital' },
+ { label: 'GitHub', url: 'https://github.com/k3-capital' },
+ { label: 'Legal', url: 'https://legal.k3.capital' },
+ ])
+ })
+})
diff --git a/utils/manager-profile.ts b/utils/manager-profile.ts
new file mode 100644
index 000000000..1044128aa
--- /dev/null
+++ b/utils/manager-profile.ts
@@ -0,0 +1,112 @@
+import type { EulerLabelEntity, EulerLabelProduct } from '~/entities/euler/labels'
+
+export type ManagerProfileExternalLink = {
+ label: string
+ url: string
+}
+
+const withProtocol = (url: string): string => {
+ if (url.startsWith('hhttps://')) return `https://${url.slice('hhttps://'.length)}`
+ if (/^https?:\/\//i.test(url)) return url
+ return `https://${url}`
+}
+
+const cleanHandle = (value: string): string => value.trim().replace(/^@+/, '').replace(/^\/+/, '')
+
+export const getManagerProfileExternalUrl = (url: string): string => withProtocol(url.trim())
+
+const SOCIAL_LINK_LABELS: Record = {
+ twitter: 'X',
+ github: 'GitHub',
+ discord: 'Discord',
+ telegram: 'Telegram',
+ youtube: 'YouTube',
+}
+
+const getSocialLinkLabel = (platform: string): string =>
+ SOCIAL_LINK_LABELS[platform] ?? platform
+ .split(/[-_\s]+/)
+ .filter(Boolean)
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(' ')
+
+export const getManagerProfileSocialUrl = (platform: string, value: string): string => {
+ const normalized = value.trim()
+ if (!normalized) return ''
+ if (/^h?https?:\/\//i.test(normalized)) return withProtocol(normalized)
+
+ const handle = cleanHandle(normalized)
+ if (!handle) return ''
+
+ switch (platform) {
+ case 'twitter':
+ return `https://x.com/${handle}`
+ case 'github':
+ return `https://github.com/${handle}`
+ case 'discord':
+ return `https://discord.gg/${handle}`
+ case 'telegram':
+ return `https://t.me/${handle}`
+ case 'youtube':
+ return `https://youtube.com/@${handle}`
+ default:
+ return withProtocol(handle)
+ }
+}
+
+export const getManagerProfileSocialLinks = (
+ entity: EulerLabelEntity,
+): ManagerProfileExternalLink[] => {
+ const social = entity.social ?? {}
+ const links: ManagerProfileExternalLink[] = ([
+ entity.url ? { label: 'Website', url: withProtocol(entity.url) } : null,
+ ...Object.entries(social).map(([platform, value]) =>
+ value
+ ? {
+ label: getSocialLinkLabel(platform),
+ url: getManagerProfileSocialUrl(platform, value),
+ }
+ : null,
+ ),
+ ] as Array).filter((link): link is ManagerProfileExternalLink => Boolean(link?.url))
+
+ const seen = new Set()
+ return links.filter((link) => {
+ const key = `${link.label}:${link.url}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+export const getEulerLabelEntityKeys = (product: EulerLabelProduct): string[] => {
+ if (Array.isArray(product.entity)) return product.entity
+ return product.entity ? [product.entity] : []
+}
+
+export const isEulerLabelProductManagedBy = (product: EulerLabelProduct, slug: string): boolean =>
+ getEulerLabelEntityKeys(product).includes(slug)
+
+export const getEulerLabelEntitySlug = (
+ entities: Record,
+ entity: EulerLabelEntity,
+): string => {
+ const exact = Object.entries(entities).find(([, value]) => value === entity)
+ if (exact) return exact[0]
+
+ const byStableFields = Object.entries(entities).find(([, value]) =>
+ value.name === entity.name
+ && value.logo === entity.logo
+ && value.url === entity.url,
+ )
+ return byStableFields?.[0] ?? ''
+}
+
+export const getEulerLabelEntityDisplayName = (entities: EulerLabelEntity[]): string => {
+ if (entities.length === 0) return ''
+ if (entities.length === 1) return entities[0].name
+ if (entities.length === 2) return `${entities[0].name} & ${entities[1].name}`
+ return `${entities[0].name} & others`
+}
+
+export const getManagerProfilePath = (slug: string): string => `/managers/${slug}`