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 @@ + + + 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
- +
{ 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 @@ + + + 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}`