Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ UPSTASH_REDIS_REST_TOKEN_PORTFOLIO=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_USERNAME=
UPSTASH_REDIS_REST_TOKEN=

# Lifetime earnings headline (landing page odometer). Falls back to the unsuffixed UPSTASH_REDIS_REST_* vars.
UPSTASH_REDIS_REST_URL_EARNINGS=
UPSTASH_REDIS_REST_TOKEN_EARNINGS=
9 changes: 7 additions & 2 deletions app/page-client.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
'use client'

import HomePage from '@pages/landing'
import type { TLifetimeEarningsHeadline } from '@shared/utils/schemas/lifetimeEarningsSchema'
import type { ReactElement } from 'react'

export default function HomePageClient(): ReactElement {
return <HomePage />
export default function HomePageClient({
earningsHeadline
}: {
earningsHeadline: TLifetimeEarningsHeadline | null
}): ReactElement {
return <HomePage earningsHeadline={earningsHeadline} />
}
11 changes: 4 additions & 7 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { JsonLd } from '@shared/components/JsonLd'
import { HydrationBoundary } from '@tanstack/react-query'
import type { ReactElement } from 'react'
import PublicApp from '@/PublicApp'
import { getLandingPageDehydratedState } from '@/server/ssr/publicDataHydration'
import { getLifetimeEarningsHeadlineOrNull } from '@/server/earnings/headline'
import { landingMetadata, yearnOrganizationJsonLd } from './metadata'
import HomePageClient from './page-client'

Expand Down Expand Up @@ -51,7 +50,7 @@ const faqJsonLd = {
},
{
'@type': 'Question',
name: 'Are there Developer Docs?',
name: 'Are there developer docs?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Yes. Yearn has documentation for developers building on top of the protocol at https://docs.yearn.fi.'
Expand All @@ -61,17 +60,15 @@ const faqJsonLd = {
}

export default async function Page(): Promise<ReactElement> {
const dehydratedState = await getLandingPageDehydratedState()
const earningsHeadline = await getLifetimeEarningsHeadlineOrNull()

return (
<>
<JsonLd schema={yearnOrganizationJsonLd} />
<JsonLd schema={websiteJsonLd} />
<JsonLd schema={faqJsonLd} />
<PublicApp>
<HydrationBoundary state={dehydratedState}>
<HomePageClient />
</HydrationBoundary>
<HomePageClient earningsHeadline={earningsHeadline} />
</PublicApp>
</>
)
Expand Down
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
},
"dependencies": {
"@headlessui/react": "2.2.10",
"@number-flow/react": "0.6.0",
"@plausible-analytics/tracker": "0.4.5",
"@rainbow-me/rainbowkit": "2.2.11",
"@react-hookz/web": "24.0.4",
Expand Down
2 changes: 1 addition & 1 deletion src/components/pages/landing/components/sections/FAQs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const faqData = [
)
},
{
title: 'Are there Developer Docs?',
title: 'Are there developer docs?',
content: (
<p>
{'Yes! Yearn has extensive documentation for developers looking to build on top of our protocol. Visit our'}{' '}
Expand Down
28 changes: 10 additions & 18 deletions src/components/pages/landing/components/sections/Hero.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { usePlausible } from '@hooks/usePlausible'
import { Button } from '@shared/components/Button'
import { LifetimeEarningsStat } from '@shared/components/LifetimeEarningsStat'
import { SectionHeader } from '@shared/components/SectionHeader'
import { TvlStat } from '@shared/components/TvlStat'
import { YEARN_TVL_ENDPOINT } from '@shared/data/publicQueryEndpoints'
import { useFetch } from '@shared/hooks/useFetch'
import { PLAUSIBLE_EVENTS } from '@shared/utils/plausible'
import type { TLifetimeEarningsHeadline } from '@shared/utils/schemas/lifetimeEarningsSchema'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import type { ReactElement } from 'react'
import { useEffect } from 'react'
import * as z from 'zod'
import Image from '/src/components/Image'

function AnimatedLogos(): ReactElement {
Expand Down Expand Up @@ -75,13 +73,9 @@ function AnimatedLogos(): ReactElement {
)
}

export function Hero(): ReactElement {
export function Hero({ earningsHeadline }: { earningsHeadline: TLifetimeEarningsHeadline | null }): ReactElement {
const trackEvent = usePlausible()
const router = useRouter()
const { data: tvl } = useFetch<number>({
endpoint: YEARN_TVL_ENDPOINT,
schema: z.number()
})

useEffect(() => {
const prefetchTimer = window.setTimeout(() => {
Expand Down Expand Up @@ -113,13 +107,13 @@ export function Hero(): ReactElement {
<div className={'flex h-full items-center justify-center'}>
<div className={'z-20 flex flex-col items-center justify-center gap-12 text-center'}>
<div className={'mb-8 mt-12'}>
<TvlStat tvl={tvl ?? 0} />
{earningsHeadline ? <LifetimeEarningsStat headline={earningsHeadline} /> : null}
</div>
<SectionHeader
isH1
align={'center'}
title={'Earn on your Crypto'}
description={"Yearn is DeFi's Yield Aggregator"}
title={'Earn on your crypto'}
description={"Yearn is DeFi's yield aggregator"}
/>
<div className={'flex flex-row items-center justify-center gap-4'}>
<Link
Expand Down Expand Up @@ -151,21 +145,19 @@ export function Hero(): ReactElement {
<div className={'mt-4 flex w-full max-w-sm flex-col items-center gap-6 text-center sm:gap-8'}>
<Image
src={'/landing/yfi-top-right.png'}
alt={'Yearn Finance Logo'}
alt={'Yearn Finance logo'}
width={180}
height={180}
className={'size-auto max-w-[120px] sm:max-w-[150px]'}
priority
/>
<div className={'mb-2'}>
<TvlStat tvl={tvl ?? 0} />
</div>
<div className={'mb-2'}>{earningsHeadline ? <LifetimeEarningsStat headline={earningsHeadline} /> : null}</div>
<div className={'flex flex-col gap-8 sm:gap-10'}>
<SectionHeader
isH1
align={'center'}
title={'Earn on your Crypto'}
description={"DeFi's most battle tested yield aggregator"}
title={'Earn on your crypto'}
description={"Yearn is DeFi's yield aggregator"}
/>
<div className={'flex flex-col items-center justify-center'}>
<Link
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export const Integrations: FC = () => (
<SectionHeader
tagline={'Partners'}
title={'Integrations'}
description={'Partners actively using Yearn vaults'}
description={'Partners actively using Yearn Vaults'}
/>
<div className={'mt-6 grid overflow-hidden rounded-lg sm:mt-8'}>
{integrations.map((integration, index) => (
Expand Down
8 changes: 4 additions & 4 deletions src/components/pages/landing/components/sections/Security.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ const Cards: {
} = {
[SecurityCardType.Audits]: {
title: 'Audits',
description: 'Yearn Contracts are audited thoroughly by a variety of auditors.',
description: 'Yearn contracts are audited thoroughly by a variety of auditors.',
href: 'https://docs.yearn.fi/developers/security/',
imageSrc: '/landing/yearn-apps-logo.png',
bgColor: 'bg-gradient-to-br from-[#0657F9] to-[#0B4DD0]'
},
[SecurityCardType.BugBounties]: {
title: 'Bug Bounties',
title: 'Bug bounties',
description: 'Security is our top priority. Report vulnerabilities and get rewarded.',
href: 'https://immunefi.com/bug-bounty/yearnfinance',
imageSrc: '/landing/integrations.png',
Expand All @@ -50,7 +50,7 @@ const SecurityCard: FC<{
'flex min-h-[48px] items-center text-base text-white/90 transition-colors duration-300 group-hover:text-white sm:min-h-0 sm:text-inherit'
}
>
{'Learn More →'}
{'Learn more →'}
</div>
</div>
</div>
Expand All @@ -66,7 +66,7 @@ export const Security: FC = () => (
<SectionHeader
align={'center'}
tagline={'Audited, secure'}
title={'Security First'}
title={'Security first'}
description={'Yearn prioritizes security to protect your assets.'}
/>
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/components/pages/landing/components/sections/Vaults.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,17 @@ const slides: TVault[] = [
image: '/landing/safe.png',
size: 250,
tagline: 'Growing every day',
title: 'Compounding Vaults',
title: 'Compounding vaults',
description: 'Vaults utilize DeFi opportunities to give you the best risk-adjusted yields',
cta: { label: 'View All', href: 'https://yearn.fi/v3' }
cta: { label: 'View all', href: 'https://yearn.fi/v3' }
},
{
background: '/landing/vault-background-y.png',
image: '/landing/apps.png',
size: 300,
tagline: 'Growing every day',
title: 'App Ecosystem',
description: 'Apps built on Yearn vaults by contributors and the wider community'
title: 'App ecosystem',
description: 'Apps built on Yearn Vaults by contributors and the wider community'
}
]

Expand Down Expand Up @@ -85,8 +85,8 @@ const appRows: TRow[] = [
},
{
icon: '/landing/apps/factory.png',
text: 'LP Token Vaults',
description: 'Permissionless Curve Autocompounders',
text: 'LP token vaults',
description: 'Permissionless Curve autocompounders',
href: 'https://yearn.fi/vaults?type=lp'
},
{
Expand Down
5 changes: 3 additions & 2 deletions src/components/pages/landing/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { TLifetimeEarningsHeadline } from '@shared/utils/schemas/lifetimeEarningsSchema'
import type { ReactElement } from 'react'
import { FAQs, Footer, Hero, Integrations, Partners, Security, Vaults } from './components/sections'

function Index(): ReactElement {
function Index({ earningsHeadline }: { earningsHeadline: TLifetimeEarningsHeadline | null }): ReactElement {
return (
<div
data-theme={'midnight'}
Expand All @@ -11,7 +12,7 @@ function Index(): ReactElement {
>
<main className={'flex w-full flex-col items-center'}>
<div className={'flex w-full flex-col items-center'}>
<Hero />
<Hero earningsHeadline={earningsHeadline} />
<Vaults />
<Security />
<Partners />
Expand Down
48 changes: 48 additions & 0 deletions src/components/shared/components/LifetimeEarningsStat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client'

import NumberFlow from '@number-flow/react'
import type { TLifetimeEarningsHeadline } from '@shared/utils/schemas/lifetimeEarningsSchema'
import type { ReactElement } from 'react'
import { useEffect, useState } from 'react'

// Tick at the whole-dollar cadence (1000 / rate ms) so each update rolls the
// last digit by ~$1, clamped for sane rates. The roll animation runs linear
// with duration matched to the tick so each roll finishes exactly as the next
// value lands — constant velocity, no rubber-band.
const TICK_MIN_MS = 50
const TICK_MAX_MS = 1_000

export function LifetimeEarningsStat({ headline }: { headline: TLifetimeEarningsHeadline }): ReactElement {
const [value, setValue] = useState(headline.value)

const tickMs = Math.min(TICK_MAX_MS, Math.max(TICK_MIN_MS, 1_000 / headline.rate_usd_per_sec))

// Timer-driven extrapolation between server reads: the figure must advance
// on wall-clock time, which has no declarative alternative.
useEffect(() => {
const extrapolate = (): number =>
headline.value + (headline.rate_usd_per_sec * (Date.now() - headline.computed_at_ms)) / 1000

setValue(extrapolate())
const timer = window.setInterval(() => setValue(extrapolate()), tickMs)
return () => window.clearInterval(timer)
}, [headline, tickMs])

return (
<div className={'flex max-w-full flex-col items-center gap-1'}>
<p className={'text-2xl sm:text-[28px] text-white text-center'}>
<span className={'font-number mr-2 opacity-75'}>{'$'}</span>
<NumberFlow
className={'font-number'}
value={value}
format={{ maximumFractionDigits: 0 }}
trend={1}
transformTiming={{ duration: tickMs, easing: 'linear' }}
spinTiming={{ duration: tickMs, easing: 'linear' }}
willChange
/>
</p>
<p className={'text-xs sm:text-sm text-white opacity-75'}>{'Lifetime earned by Yearn Vaults'}</p>
</div>
)
}
20 changes: 0 additions & 20 deletions src/components/shared/components/TvlStat.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/components/shared/data/publicQueryEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { KONG_REST_BASE } from '@pages/vaults/utils/kongRest'
import { isAddress } from 'viem'
import { toAddress } from '../utils'

export const YEARN_TVL_ENDPOINT = 'https://api.llama.fi/tvl/yearn'
export const YEARN_VAULT_LIST_ENDPOINT = `${KONG_REST_BASE}/list/vaults`

export function buildVaultSnapshotEndpoint(chainId?: number | string, address?: string): string | null {
Expand Down
21 changes: 21 additions & 0 deletions src/components/shared/utils/schemas/lifetimeEarningsSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as z from 'zod'

/**
* Lifetime-earnings headline served by /api/earnings/headline.
*
* A compute bot writes the raw snapshot fields to Redis; the server
* extrapolates them to `value` at `computed_at_ms` and the client keeps the
* number moving from there using `rate_usd_per_sec`.
*/
export const lifetimeEarningsHeadlineSchema = z.object({
net_yield_usd: z.number(),
as_of_ms: z.number(),
rate_usd_per_sec: z.number().nonnegative(),
prev_net_yield_usd: z.number(),
prev_as_of_ms: z.number(),
run_id: z.string(),
value: z.number(),
computed_at_ms: z.number()
})

export type TLifetimeEarningsHeadline = z.infer<typeof lifetimeEarningsHeadlineSchema>
Loading