diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx
index a48003ed660..5c0b6d622fd 100644
--- a/app/[locale]/page.tsx
+++ b/app/[locale]/page.tsx
@@ -7,16 +7,19 @@ import { getTranslations, setRequestLocale } from "next-intl/server"
import type {
AllHomepageActivityData,
CommunityBlog,
+ Lang,
PageParams,
ValuesPairing,
} from "@/lib/types"
import { CodeExample } from "@/lib/interfaces"
+import ABTestWrapper from "@/components/AB/TestWrapper"
import ActivityStats from "@/components/ActivityStats"
import { ChevronNext } from "@/components/Chevron"
import HomeHero from "@/components/Hero/HomeHero"
import BentoCard from "@/components/Homepage/BentoCard"
import CodeExamples from "@/components/Homepage/CodeExamples"
+import Homepage2026 from "@/components/Homepage/Homepage2026"
import HomepageSectionImage from "@/components/Homepage/HomepageSectionImage"
import { getBentoBoxItems } from "@/components/Homepage/utils"
import ValuesMarqueeFallback from "@/components/Homepage/ValuesMarquee/Fallback"
@@ -35,7 +38,9 @@ import { Image } from "@/components/Image"
import CardImage from "@/components/Image/CardImage"
import IntersectionObserverReveal from "@/components/IntersectionObserverReveal"
import MainArticle from "@/components/MainArticle"
+import ScrollDepthTracker from "@/components/ScrollDepthTracker"
import Tooltip from "@/components/Tooltip"
+import { TrackedSection } from "@/components/TrackedSection"
import { ButtonLink } from "@/components/ui/buttons/Button"
import SvgButtonLink, {
type SvgButtonLinkProps,
@@ -66,6 +71,7 @@ import { getMetadata } from "@/lib/utils/metadata"
import { formatPriceUSD } from "@/lib/utils/numbers"
import { polishRSSList } from "@/lib/utils/rss"
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
import {
BLOGS_WITHOUT_FEED,
DEFAULT_LOCALE,
@@ -79,6 +85,7 @@ import IndexPageJsonLD from "./page-jsonld"
import { getActivity } from "./utils"
import {
+ getAccountHolders,
getAppsData,
getAttestantPosts,
getBeaconchainData,
@@ -90,6 +97,9 @@ import {
} from "@/lib/data"
import EventFallback from "@/public/images/events/event-placeholder.png"
+// Force dynamic rendering to read headers for A/B testing
+export const dynamic = "force-dynamic"
+
const BentoCardSwiper = nextDynamic(
() => import("@/components/Homepage/BentoCardSwiper"),
{
@@ -136,6 +146,8 @@ const Page = async ({ params }: { params: PageParams }) => {
const { direction: dir, isRtl } = getDirection(locale)
// Fetch data using the new data-layer functions (already cached)
+ // Each fetch is wrapped with .catch() to prevent Promise.all from rejecting entirely
+ // when a single API fails - enables graceful degradation
const [
ethPrice,
beaconchainData,
@@ -145,61 +157,102 @@ const Page = async ({ params }: { params: PageParams }) => {
rssData,
appsData,
eventsData,
+ accountHolders,
] = await Promise.all([
- getEthPrice(),
- getBeaconchainData(),
- getTotalValueLockedData(),
- getGrowThePieData(),
- getAttestantPosts(),
- getRSSData(),
- getAppsData(),
- getEventsData(),
+ getEthPrice().catch(() => null),
+ getBeaconchainData().catch(() => null),
+ getTotalValueLockedData().catch(() => null),
+ getGrowThePieData().catch(() => null),
+ getAttestantPosts().catch(() => null),
+ getRSSData().catch(() => null),
+ getAppsData().catch(() => null),
+ getEventsData().catch(() => null),
+ getAccountHolders().catch(() => null),
])
- // Handle null cases - throw error if required data is missing
+ // Graceful degradation: log errors and use fallback values
+ // With force-dynamic, there's no ISR cache to fall back to, so we must handle failures gracefully
+
+ // Error fallback helper
+ const createErrorMetric = (error: string) => ({ error })
+
+ // ETH Price - show "—" on failure
if (!ethPrice) {
- throw new Error("Failed to fetch ETH price data")
+ console.error("[Homepage] Failed to fetch ETH price data")
}
+ const safeEthPrice =
+ ethPrice ?? createErrorMetric("Failed to fetch ETH price")
+
+ // Beaconchain data - show "—" on failure
if (!beaconchainData) {
- throw new Error("Failed to fetch Beaconchain data")
+ console.error("[Homepage] Failed to fetch Beaconchain data")
}
+ const totalEthStaked =
+ beaconchainData?.totalEthStaked ??
+ createErrorMetric("Failed to fetch staked ETH")
+
+ // Total Value Locked - show "—" on failure
if (!totalValueLocked) {
- throw new Error("Failed to fetch total value locked data")
+ console.error("[Homepage] Failed to fetch TVL data")
}
+ const safeTotalValueLocked =
+ totalValueLocked ?? createErrorMetric("Failed to fetch TVL")
+
+ // GrowThePie data - show "—" on failure
if (!growThePieData) {
- throw new Error("Failed to fetch GrowThePie data")
+ console.error("[Homepage] Failed to fetch GrowThePie data")
+ }
+ const safeTxCount =
+ growThePieData?.txCount ?? createErrorMetric("Failed to fetch tx count")
+ const safeTxCostsMedianUsd =
+ growThePieData?.txCostsMedianUsd ??
+ createErrorMetric("Failed to fetch tx costs")
+
+ // Account holders - show "—" on failure (only used by redesign variants)
+ if (!accountHolders || "error" in accountHolders) {
+ console.error("[Homepage] Failed to fetch account holders data")
}
+ const accountHoldersValue =
+ accountHolders && "value" in accountHolders ? accountHolders.value : null
+
+ // Transactions today for KPIs (redesign variants) - show "—" on failure
+ const transactionsToday =
+ growThePieData && "value" in growThePieData.txCount
+ ? growThePieData.txCount.value
+ : null
+
+ // Apps data - hide section on failure
if (!appsData) {
- throw new Error("Failed to fetch apps data")
+ console.error("[Homepage] Failed to fetch apps data")
}
+ const hasAppsData = !!appsData
- // RSS feeds - graceful degradation: use what's available if we have enough items
+ // RSS feeds - hide section if insufficient items
const rssFeeds = rssData ?? []
const attestantFeed = attestantPosts ?? []
const totalRssItems =
rssFeeds.reduce((sum, feed) => sum + feed.length, 0) + attestantFeed.length
if (totalRssItems < RSS_DISPLAY_COUNT) {
- throw new Error(
- `Insufficient RSS data: need at least ${RSS_DISPLAY_COUNT} items`
+ console.error(
+ `[Homepage] Insufficient RSS data: have ${totalRssItems}, need ${RSS_DISPLAY_COUNT}`
)
}
-
- // Extract totalEthStaked from beaconchainData
- const { totalEthStaked } = beaconchainData
+ const hasEnoughRssItems = totalRssItems >= RSS_DISPLAY_COUNT
// Events - use empty array as fallback
const upcomingEvents = (eventsData ?? []).slice(0, 3)
- const appsOfTheWeek = parseAppsOfTheWeek(appsData)
+ // Apps of the week - only parse if we have data
+ const appsOfTheWeek = hasAppsData ? parseAppsOfTheWeek(appsData) : []
const bentoItems = await getBentoBoxItems(locale)
- const ethPriceHasError = "error" in ethPrice
+ const ethPriceHasError = "error" in safeEthPrice
const price = ethPriceHasError
- ? t("loading-error-refresh")
- : formatPriceUSD(ethPrice.value, locale)
+ ? "—"
+ : formatPriceUSD(safeEthPrice.value, locale)
const eventCategory = `Homepage - ${locale}`
@@ -210,7 +263,7 @@ const Page = async ({ params }: { params: PageParams }) => {
href: "/wallets/find-wallet/",
Svg: PickWalletIcon,
className: "text-primary hover:text-primary-hover",
- eventName: "find wallet",
+ eventName: "find_wallet",
},
{
label: t("page-index-cta-get-eth-label"),
@@ -218,7 +271,7 @@ const Page = async ({ params }: { params: PageParams }) => {
href: "/get-eth/",
Svg: EthTokenIcon,
className: "text-accent-a hover:text-accent-a-hover",
- eventName: "get eth",
+ eventName: "get_eth",
},
{
label: t("page-index-cta-dapps-label"),
@@ -229,7 +282,7 @@ const Page = async ({ params }: { params: PageParams }) => {
"text-accent-c hover:text-accent-c-hover",
isRtl && "[&_svg]:-scale-x-100"
),
- eventName: "dapps",
+ eventName: "try_apps",
},
{
label: t("page-index-cta-build-apps-label"),
@@ -237,7 +290,7 @@ const Page = async ({ params }: { params: PageParams }) => {
href: "/developers/",
Svg: BuildAppsIcon,
className: "text-accent-b hover:text-accent-b-hover",
- eventName: "build apps",
+ eventName: "start_building",
},
]
@@ -425,536 +478,616 @@ const Page = async ({ params }: { params: PageParams }) => {
]
const metricResults: AllHomepageActivityData = {
- ethPrice,
+ ethPrice: safeEthPrice,
totalEthStaked,
- totalValueLocked,
- txCount: growThePieData.txCount,
- txCostsMedianUsd: growThePieData.txCostsMedianUsd,
+ totalValueLocked: safeTotalValueLocked,
+ txCount: safeTxCount,
+ txCostsMedianUsd: safeTxCostsMedianUsd,
}
const metrics = await getActivity(metricResults, locale)
- // RSS feed items
- // polishRSSList expects RSSItem[][], so wrap attestantFeed in an array
- const polishedRssItems = polishRSSList([attestantFeed, ...rssFeeds], locale)
+ // RSS feed items - only process if we have enough items
+ const polishedRssItems = hasEnoughRssItems
+ ? polishRSSList([attestantFeed, ...rssFeeds], locale)
+ : []
const rssItems = polishedRssItems.slice(0, RSS_DISPLAY_COUNT)
- const blogLinks = polishedRssItems.map(({ source, sourceUrl }) => ({
- name: source,
- href: sourceUrl,
- })) as CommunityBlog[]
- blogLinks.push(...BLOGS_WITHOUT_FEED)
+ const blogLinks = hasEnoughRssItems
+ ? ([
+ ...polishedRssItems.map(({ source, sourceUrl }) => ({
+ name: source,
+ href: sourceUrl,
+ })),
+ ...BLOGS_WITHOUT_FEED,
+ ] as CommunityBlog[])
+ : []
return (
<>
-
-
-
-
- {subHeroCTAs.map(
- ({ label, description, href, className, Svg }, idx) => {
- const Link = (
- props: Omit<
- SvgButtonLinkProps,
- "Svg" | "href" | "label" | "children"
- >
- ) => (
-
- {description}
-
- )
- return (
-
-
-
-
- )
- }
- )}
-
-
- {/* What is Ethereum */}
-
+
-
-
-
-
-
- {t("page-index-network-tag")}
-
- {t("page-index-what-is-ethereum-title")}
-
-
-
{t("page-index-what-is-ethereum-description-1")}
-
{t("page-index-what-is-ethereum-description-2")}
-
-
-
- {t("page-index-what-is-ethereum-action")}
-
-
-
- {/* Popular topics */}
-
-
- {t("page-index-popular-topics-header")}
-
-
- {popularTopics
- .filter((topic) => topic.href !== "/what-is-ethereum/")
- .map(({ label, Svg, href, eventName, className }) => (
+
+
+
+ {subHeroCTAs.map(
+ ({ label, description, href, className, Svg }, idx) => {
+ const Link = (
+ props: Omit<
+ SvgButtonLinkProps,
+ "Svg" | "href" | "label" | "children"
+ >
+ ) => (
:first-child]:flex-row",
- className
- )}
+ label={label}
customEventOptions={{
eventCategory,
- eventAction: "popular topics",
- eventName,
+ eventAction: "cta_click",
+ eventName: subHeroCTAs[idx].eventName,
}}
+ {...props}
>
-
- {label}
-
+ {description}
- ))}
-
-
-
-
-
- {/* Use Cases - A new way to use the internet */}
-
-
-
- {t("page-index-use-cases-tag")}
+ )
+ return (
+
+
+
+
+ )
+ }
+ )}
-
- {t("page-index-bento-header")}
-
-
- {/* Mobile - dynamic / lazy loaded */}
-
-
- {/* Desktop */}
- {bentoItems.map(({ className, ...item }) => (
-
- ))}
-
-
- {/* What is ETH */}
-
-
-
-
-
-
- {t("page-index-token-tag")}
-
- {t("page-index-what-is-ether-title")}
-
-
-
{t("page-index-what-is-ether-description-1")}
-
{t("page-index-what-is-ether-description-2")}
-
-
-
+
+
+
+
+
+
+ {t("page-index-network-tag")}
+
+ {t("page-index-what-is-ethereum-title")}
+
+
+
{t("page-index-what-is-ethereum-description-1")}
+
{t("page-index-what-is-ethereum-description-2")}
+
+
+
+ {t("page-index-what-is-ethereum-action")}{" "}
+
+
+
+
+ {/* Popular topics */}
+
+
+ {t("page-index-popular-topics-header")}
+
+
+ {popularTopics
+ .filter(
+ (topic) => topic.href !== "/what-is-ethereum/"
+ )
+ .map(({ label, Svg, href, eventName, className }) => (
+
:first-child]:flex-row",
+ className
+ )}
+ customEventOptions={{
+ eventCategory,
+ eventAction: "popular topics",
+ eventName,
+ }}
+ >
+
+ {label}
+
+
+ ))}
+
+
+
+
+
+
+ {/* Use Cases - A new way to use the internet */}
+
+
-
- {tCommon("eth-current-price")}
-
- {tCommon("data-provided-by")}{" "}
-
- coingecko.com
-
-
- }
+
-
-
-
-
-
-
- {t("page-index-what-is-ether-action")}
-
-
-
-
-
- {/* Apps of the week - Discover the best apps on Ethereum */}
- {/* // TODO: Remove locale restriction after translation */}
- {locale === DEFAULT_LOCALE && (
-
-
-
-
Apps of the week
-
Discover apps on Ethereum
-
Start exploring Ethereum today
-
-
-
-
- Browse apps
-
-
-
-
- )}
-
- {/* Activity - The strongest ecosystem */}
-
-
-
-
-
-
- {t("page-index-activity-tag")}
- {t("page-index-activity-header")}
-
-
- {t("page-index-activity-description")}
-
-
- {t("page-index-activity-subtitle")}
-
-
-
-
-
- {t("page-index-activity-action-primary")}
-
-
- {t("page-index-activity-action")}
-
-
-
-
-
-
- {/* Values - The Internet Is Changing */}
-
-
- {t("page-index-values-tag")}
- {t("page-index-values-header")}
-
- {t("page-index-values-description")}
-
-
-
- {/* dynamic / lazy loaded */}
-
-
-
-
-
- {/* Builders - Blockchain's biggest builder community */}
-
-
-
-
-
-
- {t("page-index-builders-tag")}
- {t("page-index-builders-header")}
- {t("page-index-builders-description")}
-
-
+ {t("page-index-use-cases-tag")}
+
+
+ {t("page-index-bento-header")}
+
+
+
+ {/* Mobile - dynamic / lazy loaded */}
+
+
+ {/* Desktop */}
+ {bentoItems.map(({ className, ...item }) => (
+
+ ))}
+
+
+
+ {/* What is ETH */}
+
+
+
+
+
+
+
+ {t("page-index-token-tag")}
+
+ {t("page-index-what-is-ether-title")}
+
+
+
{t("page-index-what-is-ether-description-1")}
+
{t("page-index-what-is-ether-description-2")}
+
+
+
{price}
+
+ {tCommon("eth-current-price")}
+
+ {tCommon("data-provided-by")}{" "}
+
+ coingecko.com
+
+
+ }
+ >
+
+
+
+
+
+
+ {t("page-index-what-is-ether-action")}
+
+
+
+
+
+
+ {/* Apps of the week - Discover the best apps on Ethereum */}
+ {/* // TODO: Remove locale restriction after translation */}
+ {locale === DEFAULT_LOCALE && hasAppsData && (
+
- {t("page-index-builders-action-primary")}
-
-
+
+
+
Apps of the week
+
Discover apps on Ethereum
+
+ Start exploring Ethereum today
+
+
+
+
+
+ Browse apps
+
+
+
+
+
+ )}
+
+ {/* Activity - The strongest ecosystem */}
+
+
- {t("page-index-builders-action-secondary")}
-
-
-
- {/* CLIENT SIDE */}
-
-
-
-
-
- {/* Recent posts */}
-
-
- {t("page-index-posts-header")}
-
- {t("page-index-posts-subtitle")}
-
- {/* dynamic / lazy loaded */}
-
-
-
-
{t("page-index-posts-action")}
-
- {blogLinks.map(({ name, href }) => (
-
- {name}
-
- ))}
-
-
-
-
- {/* Events */}
-
-
- {t("page-index-events-header")}
-
- {t("page-index-events-subtitle")}
-
-
- {upcomingEvents.map(
- (
- {
- id,
- title,
- link,
- location,
- startTime,
- endTime,
- bannerImage,
- },
- idx
- ) => (
-
+
+
+
+
+ {t("page-index-activity-tag")}
+
+ {t("page-index-activity-header")}
+
+
+
+ {t("page-index-activity-description")}
+
+
+ {t("page-index-activity-subtitle")}
+
+
+
+
+
+ {t("page-index-activity-action-primary")}{" "}
+
+
+
+ {t("page-index-activity-action")}
+
+
+
+
+
+
+
+ {/* Values - The Internet Is Changing */}
+
+
+
+ {t("page-index-values-tag")}
+
+ {t("page-index-values-header")}
+
+
+ {t("page-index-values-description")}
+
+
+
+ {/* dynamic / lazy loaded */}
+
+
+
+
+
+
+ {/* Builders - Blockchain's biggest builder community */}
+
+
+
+
+
+
+
+ {t("page-index-builders-tag")}
+
+ {t("page-index-builders-header")}
+
+
+ {t("page-index-builders-description")}
+
+
+
+ {t("page-index-builders-action-primary")}{" "}
+
+
+
+ {t("page-index-builders-action-secondary")}
+
+
+
+ {/* CLIENT SIDE */}
+
+
+
+
+
+
+ {/* Recent posts - hide if insufficient RSS items */}
+ {hasEnoughRssItems && (
+
+
+
+ {t("page-index-posts-header")}
+
+ {t("page-index-posts-subtitle")}
+
+ {/* dynamic / lazy loaded */}
+
+
+
+
{t("page-index-posts-action")}
+
+ {blogLinks.map(({ name, href }) => (
+
+ {name}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Events */}
+
+
+
+ {t("page-index-events-header")}
+
+ {t("page-index-events-subtitle")}
+
+
+ {upcomingEvents.map(
+ (
+ {
+ id,
+ title,
+ link,
+ location,
+ startTime,
+ endTime,
+ bannerImage,
+ },
+ idx
+ ) => (
+
+
+ {bannerImage ? (
+
+ ) : (
+
+ )}
+
+
+ {title}
+
+ {formatDateRange(startTime, endTime, locale, {
+ month: "long",
+ year: "numeric",
+ })}
+
+
+ {location}
+
+
+
+ )
)}
+
+
+
+
-
- {bannerImage ? (
-
- ) : (
-
- )}
-
-
- {title}
-
- {formatDateRange(startTime, endTime, locale, {
- month: "long",
- year: "numeric",
- })}
-
-
- {location}
-
-
-
- )
- )}
-
-
-
-
- {t("page-index-events-action")}
-
-
-
-
- {/* Join ethereum.org */}
-
-
-
-
{t("page-index-join-header")}
-
{t("page-index-join-description")}
-
-
- {joinActions.map(
- ({ Svg, label, href, className, description, eventName }) => (
-
- {description}
-
- )
- )}
-
-
-
+
+
+
+
+
+ {/* Join ethereum.org */}
+
+
- {t("page-index-join-action-hub")}
-
-
+
+
+
{t("page-index-join-header")}
+
{t("page-index-join-description")}
+
+
+ {joinActions.map(
+ ({
+ Svg,
+ label,
+ href,
+ className,
+ description,
+ eventName,
+ }) => (
+
+ {description}
+
+ )
+ )}
+
+
+
+ {t("page-index-join-action-hub")}
+
+
+
+
+
-
-
-
+ ,
+ ,
+ ,
+ ]}
+ />
>
)
}
diff --git a/app/[locale]/what-is-ethereum/page.tsx b/app/[locale]/what-is-ethereum/page.tsx
index ffd334d8ab6..7bd8acf3e0b 100644
--- a/app/[locale]/what-is-ethereum/page.tsx
+++ b/app/[locale]/what-is-ethereum/page.tsx
@@ -35,6 +35,8 @@ import { getAppPageContributorInfo } from "@/lib/utils/contributors"
import { getMetadata } from "@/lib/utils/metadata"
import { screens } from "@/lib/utils/screen"
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
+
import WhatIsEthereumPageJsonLD from "./page-jsonld"
import contributionBanner from "@/public/images/doge-computer.png"
@@ -788,7 +790,7 @@ const Page = async ({ params }: { params: PageParams }) => {
})}
-
+
{t("page-what-is-ethereum-start-business-cta")}
diff --git a/app/api/ab-config/route.ts b/app/api/ab-config/route.ts
index 318b8061dc2..04fbf6b327b 100644
--- a/app/api/ab-config/route.ts
+++ b/app/api/ab-config/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server"
-import { IS_PREVIEW_DEPLOY, IS_PROD } from "@/lib/utils/env"
+import { IS_PROD } from "@/lib/utils/env"
import type { ABTestConfig, MatomoExperiment } from "@/lib/ab-testing/types"
@@ -33,8 +33,9 @@ const getPreviewConfig = () => ({
export async function GET() {
// Preview mode: Show menu with original default
- if (!IS_PROD || IS_PREVIEW_DEPLOY)
+ if (!IS_PROD) {
return NextResponse.json(getPreviewConfig())
+ }
try {
const matomoUrl = process.env.NEXT_PUBLIC_MATOMO_URL
diff --git a/public/images/homepage/built-to-last.png b/public/images/homepage/built-to-last.png
new file mode 100644
index 00000000000..25ed701c9b7
Binary files /dev/null and b/public/images/homepage/built-to-last.png differ
diff --git a/public/images/homepage/eth.svg b/public/images/homepage/eth.svg
new file mode 100644
index 00000000000..0b290ef9a45
--- /dev/null
+++ b/public/images/homepage/eth.svg
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/images/homepage/features/free-access.png b/public/images/homepage/features/free-access.png
new file mode 100644
index 00000000000..5b6b58f8f4a
Binary files /dev/null and b/public/images/homepage/features/free-access.png differ
diff --git a/public/images/homepage/features/global.png b/public/images/homepage/features/global.png
new file mode 100644
index 00000000000..ca0c8c94772
Binary files /dev/null and b/public/images/homepage/features/global.png differ
diff --git a/public/images/homepage/features/ownership.png b/public/images/homepage/features/ownership.png
new file mode 100644
index 00000000000..2b866101b82
Binary files /dev/null and b/public/images/homepage/features/ownership.png differ
diff --git a/public/images/homepage/features/public-rules.png b/public/images/homepage/features/public-rules.png
new file mode 100644
index 00000000000..d974f0811c5
Binary files /dev/null and b/public/images/homepage/features/public-rules.png differ
diff --git a/public/images/homepage/get-started/developers.png b/public/images/homepage/get-started/developers.png
new file mode 100644
index 00000000000..7aad6016d69
Binary files /dev/null and b/public/images/homepage/get-started/developers.png differ
diff --git a/public/images/homepage/get-started/enterprise.png b/public/images/homepage/get-started/enterprise.png
new file mode 100644
index 00000000000..010c880eb37
Binary files /dev/null and b/public/images/homepage/get-started/enterprise.png differ
diff --git a/public/images/homepage/get-started/learn.png b/public/images/homepage/get-started/learn.png
new file mode 100644
index 00000000000..c181289bd7c
Binary files /dev/null and b/public/images/homepage/get-started/learn.png differ
diff --git a/public/images/homepage/logos/blackrock.webp b/public/images/homepage/logos/blackrock.webp
new file mode 100644
index 00000000000..33996606e15
Binary files /dev/null and b/public/images/homepage/logos/blackrock.webp differ
diff --git a/public/images/homepage/logos/jpmorgan.png b/public/images/homepage/logos/jpmorgan.png
new file mode 100644
index 00000000000..3ae00590cbb
Binary files /dev/null and b/public/images/homepage/logos/jpmorgan.png differ
diff --git a/public/images/homepage/logos/mastercard.png b/public/images/homepage/logos/mastercard.png
new file mode 100644
index 00000000000..2c889eb034b
Binary files /dev/null and b/public/images/homepage/logos/mastercard.png differ
diff --git a/public/images/homepage/logos/paypal.png b/public/images/homepage/logos/paypal.png
new file mode 100644
index 00000000000..187d5ae6680
Binary files /dev/null and b/public/images/homepage/logos/paypal.png differ
diff --git a/public/images/homepage/logos/robinhood.png b/public/images/homepage/logos/robinhood.png
new file mode 100644
index 00000000000..b772ba2852d
Binary files /dev/null and b/public/images/homepage/logos/robinhood.png differ
diff --git a/public/images/homepage/logos/visa.png b/public/images/homepage/logos/visa.png
new file mode 100644
index 00000000000..ab61534bad8
Binary files /dev/null and b/public/images/homepage/logos/visa.png differ
diff --git a/public/images/homepage/savings/borrowing.png b/public/images/homepage/savings/borrowing.png
new file mode 100644
index 00000000000..f46b1d3ce7a
Binary files /dev/null and b/public/images/homepage/savings/borrowing.png differ
diff --git a/public/images/homepage/savings/defi.png b/public/images/homepage/savings/defi.png
new file mode 100644
index 00000000000..584015a333d
Binary files /dev/null and b/public/images/homepage/savings/defi.png differ
diff --git a/public/images/homepage/savings/remittances.png b/public/images/homepage/savings/remittances.png
new file mode 100644
index 00000000000..4ffba043a1a
Binary files /dev/null and b/public/images/homepage/savings/remittances.png differ
diff --git a/src/components/BigNumber/index.tsx b/src/components/BigNumber/index.tsx
index 1fd241f1dc6..24d03bcd183 100644
--- a/src/components/BigNumber/index.tsx
+++ b/src/components/BigNumber/index.tsx
@@ -118,9 +118,18 @@ const BigNumber = async ({
>
) : (
-
- {t("loading-error-refresh")}
-
+ <>
+
+ —
+
+
+ {children}
+
+ >
)}
)
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 3accc2f1b5a..608a9785faa 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -13,6 +13,8 @@ import Translation from "@/components/Translation"
import { cn } from "@/lib/utils/cn"
import { scrollIntoView } from "@/lib/utils/scrollIntoView"
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
+
import { Button } from "./ui/buttons/Button"
import { BaseLink } from "./ui/Link"
import { List, ListItem } from "./ui/list"
@@ -185,7 +187,7 @@ const Footer = ({ lastDeployLocaleTimestamp }: FooterProps) => {
text: t("nav-docs-design-label"),
},
{
- href: "https://institutions.ethereum.org/",
+ href: ENTERPRISE_ETHEREUM_URL,
text: t("enterprise-mainnet"),
},
{
diff --git a/src/components/Hero/HomeHero2026/index.tsx b/src/components/Hero/HomeHero2026/index.tsx
new file mode 100644
index 00000000000..06ded61ee13
--- /dev/null
+++ b/src/components/Hero/HomeHero2026/index.tsx
@@ -0,0 +1,187 @@
+import { Fragment } from "react"
+import { getImageProps, type StaticImageData } from "next/image"
+
+import type { ClassNameProp } from "@/lib/types"
+
+import LanguageMorpher from "@/components/Homepage/LanguageMorpher"
+import PersonaModalCTA from "@/components/Homepage/PersonaModalCTA"
+import EthGlyphIcon from "@/components/icons/eth-glyph.svg"
+import EthTokenIcon from "@/components/icons/eth-token.svg"
+import EthWalletIcon from "@/components/icons/eth-wallet.svg"
+import TryAppsIcon from "@/components/icons/phone-homescreen.svg"
+import SvgButtonLink, {
+ type SvgButtonLinkProps,
+} from "@/components/ui/buttons/SvgButtonLink"
+
+import { cn } from "@/lib/utils/cn"
+import { breakpointAsNumber } from "@/lib/utils/screen"
+
+import heroBase from "@/public/images/home/hero.png"
+import hero2xl from "@/public/images/home/hero-2xl.png"
+
+export type CTAVariant = "modal" | "direct-buttons"
+
+type HomeHero2026Props = ClassNameProp & {
+ image?: StaticImageData
+ image2xl?: StaticImageData
+ alt?: string
+ ctaVariant?: CTAVariant
+ eventCategory?: string
+}
+
+const directButtonCTAs = [
+ {
+ label: "Learn Ethereum",
+ description: "What is Ethereum?",
+ href: "/what-is-ethereum/",
+ Svg: EthGlyphIcon,
+ className: "text-accent-a hover:text-accent-a-hover",
+ eventName: "learn_ethereum",
+ },
+ {
+ label: "Pick a wallet",
+ description: "Create accounts, manage assets",
+ href: "/wallets/find-wallet/",
+ Svg: EthWalletIcon,
+ className: "text-primary hover:text-primary-hover",
+ eventName: "pick_wallet",
+ },
+ {
+ label: "Get ETH",
+ description: "The currency of Ethereum",
+ href: "/get-eth/",
+ Svg: EthTokenIcon,
+ className: "text-accent-b hover:text-accent-b-hover",
+ eventName: "get_eth",
+ },
+ {
+ label: "Try apps",
+ description: "See what Ethereum can do",
+ href: "/dapps/",
+ Svg: TryAppsIcon,
+ className: "text-accent-c hover:text-accent-c-hover",
+ eventName: "try_apps",
+ },
+]
+
+const HomeHero2026 = ({
+ className,
+ image,
+ image2xl,
+ alt: altProp,
+ ctaVariant = "modal",
+ eventCategory = "Homepage",
+}: HomeHero2026Props) => {
+ const baseImage = image ?? heroBase
+ const xlImage = image2xl ?? image ?? hero2xl
+ const alt = altProp ?? "Ethereum illustration"
+
+ const common = {
+ alt,
+ sizes: `(max-width: ${breakpointAsNumber["2xl"]}px) 100vw, ${breakpointAsNumber["2xl"]}px`,
+ priority: true,
+ }
+
+ const {
+ props: { srcSet: srcSet2xl },
+ } = getImageProps({ ...common, ...xlImage, quality: 20 })
+
+ const {
+ props: { srcSet: srcSetMd },
+ } = getImageProps({ ...common, ...baseImage, quality: 10 })
+
+ const {
+ props: { srcSet: srcSetBase, ...rest },
+ } = getImageProps({ ...common, ...baseImage, quality: 5 })
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The internet that the world can rely on.
+
+
+
+ Ethereum is the global network where you control your assets, your
+ data, and your identity.
+
+
+ {ctaVariant === "modal" ? (
+
+ ) : (
+
+ {directButtonCTAs.map(
+ ({
+ label,
+ description,
+ href,
+ className: ctaClass,
+ Svg,
+ eventName,
+ }) => {
+ const Link = (
+ props: Omit<
+ SvgButtonLinkProps,
+ "Svg" | "href" | "label" | "children"
+ >
+ ) => (
+
+ {description}
+
+ )
+ return (
+
+
+
+
+ )
+ }
+ )}
+
+ )}
+
+
+
+
+ )
+}
+
+export default HomeHero2026
diff --git a/src/components/Homepage/BentoCardSwiper.tsx b/src/components/Homepage/BentoCardSwiper.tsx
index 521cfc3ad5b..7cb57c77558 100644
--- a/src/components/Homepage/BentoCardSwiper.tsx
+++ b/src/components/Homepage/BentoCardSwiper.tsx
@@ -32,8 +32,8 @@ const BentoCardSwiper = ({
onSlideChange={({ activeIndex }) => {
trackCustomEvent({
eventCategory,
- eventAction: "mobile use cases",
- eventName: `swipe to card ${activeIndex + 1}`,
+ eventAction: "cta_swipe",
+ eventName: String(activeIndex + 1),
})
}}
>
diff --git a/src/components/Homepage/FeatureCards.tsx b/src/components/Homepage/FeatureCards.tsx
new file mode 100644
index 00000000000..a26e9e76829
--- /dev/null
+++ b/src/components/Homepage/FeatureCards.tsx
@@ -0,0 +1,157 @@
+import { ChevronNext } from "@/components/Chevron"
+import { Image } from "@/components/Image"
+import { ButtonLink } from "@/components/ui/buttons/Button"
+import { Section, SectionHeader } from "@/components/ui/section"
+
+import { cn } from "@/lib/utils/cn"
+
+import freeAccessImage from "@/public/images/homepage/features/free-access.png"
+import globalImage from "@/public/images/homepage/features/global.png"
+import ownershipImage from "@/public/images/homepage/features/ownership.png"
+import publicRulesImage from "@/public/images/homepage/features/public-rules.png"
+
+type FeatureCardsProps = {
+ className?: string
+ eventCategory?: string
+}
+
+const FeatureCards = ({
+ className,
+ eventCategory = "Homepage",
+}: FeatureCardsProps) => {
+ return (
+
+
+
+
+ What makes Ethereum different
+
+
+ Principles set Ethereum apart from traditional systems
+
+
+
+
+
+
+
+
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+ Direct ownership
+
+
+
+ Your bank balance is a{" "}
+ custody promise .{" "}
+
+ Your Ethereum balance is true ownership.
+
+
+
+
+
4.6B+
+
Daily trading volume
+
+
+
+
+
+
+
+
+
+ Public rules
+
+
+ The code is public, agreements execute exactly as written.
+ Think vending machine versus hoping the cashier gives correct
+ change.
+
+
+
+
+
+
+
+
+
+
+
Global
+
+ Anyone, anywhere can use Ethereum. No permission needed.
+
+
+
+
+
+
+
+
+
Free access
+
+ No credit check, no minimum balance, no account approval. If
+ you have internet, you're in.
+
+
+
+
+
+
Nobody owns Ethereum
+
+ Changes happen through open proposals that anyone can
+ participate in. Think community garden versus corporate farm.
+
+
+
+
+
+
+
+ What is Ethereum?
+
+
+
+
+ )
+}
+
+export default FeatureCards
diff --git a/src/components/Homepage/FloatingCard.tsx b/src/components/Homepage/FloatingCard.tsx
new file mode 100644
index 00000000000..f381bbe342f
--- /dev/null
+++ b/src/components/Homepage/FloatingCard.tsx
@@ -0,0 +1,29 @@
+import { cn } from "@/lib/utils/cn"
+
+type FloatingCardProps = {
+ variant?: "default" | "primary"
+ className?: string
+ children: React.ReactNode
+}
+
+const FloatingCard = ({
+ variant = "default",
+ className,
+ children,
+}: FloatingCardProps) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export default FloatingCard
diff --git a/src/components/Homepage/GetStartedGrid.tsx b/src/components/Homepage/GetStartedGrid.tsx
new file mode 100644
index 00000000000..1d8cb940d65
--- /dev/null
+++ b/src/components/Homepage/GetStartedGrid.tsx
@@ -0,0 +1,168 @@
+import { Book, Building2, ChevronRight, Code } from "lucide-react"
+
+import { Image } from "@/components/Image"
+import { Card, CardContent } from "@/components/ui/card"
+import { LinkBox, LinkOverlay } from "@/components/ui/link-box"
+import { Section, SectionHeader } from "@/components/ui/section"
+
+import { cn } from "@/lib/utils/cn"
+
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
+
+import developersImage from "@/public/images/homepage/get-started/developers.png"
+import enterpriseImage from "@/public/images/homepage/get-started/enterprise.png"
+import learnImage from "@/public/images/homepage/get-started/learn.png"
+
+const cards = [
+ {
+ id: "learn",
+ icon: Book,
+ iconBg: "bg-[#f7ecff]",
+ iconColor: "text-primary",
+ title: "Understand Ethereum",
+ description:
+ "Start here. Learn what it is, why it matters, and how it works in plain language.",
+ bullets: [
+ "What is Ethereum?",
+ "How do wallets work?",
+ "DeFi, stablecoins, and NFTs explained",
+ ],
+ bulletColor: "bg-primary",
+ cta: "Start learning",
+ href: "/learn/",
+ image: learnImage,
+ },
+ {
+ id: "developers",
+ icon: Code,
+ iconBg: "bg-[#e9f4ff]",
+ iconColor: "text-accent-a",
+ title: "Start building",
+ description:
+ "For developers. Access documentation, tools, and tutorials to build on Ethereum.",
+ bullets: [
+ "Developer documentation",
+ "Smart contract tutorials",
+ "Development tools & frameworks",
+ ],
+ bulletColor: "bg-accent-a",
+ cta: "View materials",
+ href: "/developers/",
+ image: developersImage,
+ },
+ {
+ id: "enterprise",
+ icon: Building2,
+ iconBg: "bg-[#e6f7f6]",
+ iconColor: "text-accent-c",
+ title: "For enterprise",
+ description:
+ "Business use cases, institutional resources, and how Ethereum can serve your organization.",
+ bullets: [
+ "Enterprise use cases",
+ "Private & permissioned networks",
+ "Institutional resources",
+ ],
+ bulletColor: "bg-accent-c",
+ cta: "Explore enterprise",
+ href: ENTERPRISE_ETHEREUM_URL,
+ image: enterpriseImage,
+ },
+]
+
+type GetStartedGridProps = {
+ className?: string
+ eventCategory?: string
+}
+
+const GetStartedGrid = ({
+ className,
+ eventCategory = "Homepage",
+}: GetStartedGridProps) => {
+ return (
+
+
+
+
+ Get started on Ethereum
+
+
+ Takes 2 minutes to get started. No credit check, no paperwork, no
+ minimum balance.
+
+
+
+
+ {cards.map((card) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {card.title}
+
+
+
+
{card.description}
+
+
+ {card.bullets.map((bullet) => (
+
+
+
+ {bullet}
+
+
+ ))}
+
+
+
+
+ {card.cta}
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
+
+export default GetStartedGrid
diff --git a/src/components/Homepage/Homepage2026.tsx b/src/components/Homepage/Homepage2026.tsx
new file mode 100644
index 00000000000..59a06d9c6e5
--- /dev/null
+++ b/src/components/Homepage/Homepage2026.tsx
@@ -0,0 +1,94 @@
+import { Suspense } from "react"
+import dynamic from "next/dynamic"
+
+import type { Lang } from "@/lib/types"
+
+import HomeHero2026, { type CTAVariant } from "@/components/Hero/HomeHero2026"
+import FeatureCards from "@/components/Homepage/FeatureCards"
+import GetStartedGrid from "@/components/Homepage/GetStartedGrid"
+import { SimulatorI18nWrapper } from "@/components/Homepage/SimulatorSection/SimulatorI18nWrapper"
+import TrustLogos from "@/components/Homepage/TrustLogos"
+import MainArticle from "@/components/MainArticle"
+import { TrackedSection } from "@/components/TrackedSection"
+import { Section } from "@/components/ui/section"
+
+import { getDirection } from "@/lib/utils/direction"
+
+// Heavy client components loaded dynamically for better code splitting
+const KPISection = dynamic(() => import("@/components/Homepage/KPISection"))
+const SavingsCarousel = dynamic(
+ () => import("@/components/Homepage/SavingsCarousel")
+)
+const SimulatorSection = dynamic(
+ () => import("@/components/Homepage/SimulatorSection")
+)
+
+const SectionSkeleton = ({ className }: { className?: string }) => (
+
+)
+
+type Homepage2026Props = {
+ locale: Lang
+ accountHolders: number | null
+ transactionsToday: number | null
+ ctaVariant?: CTAVariant
+}
+
+const Homepage2026 = ({
+ locale,
+ accountHolders,
+ transactionsToday,
+ ctaVariant = "modal",
+}: Homepage2026Props) => {
+ const { direction: dir } = getDirection(locale)
+
+ const eventCategory = `Homepage - ${locale}`
+
+ return (
+
+
+
+
+
+ }>
+
+
+
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }>
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default Homepage2026
diff --git a/src/components/Homepage/KPISection.tsx b/src/components/Homepage/KPISection.tsx
new file mode 100644
index 00000000000..65c56be92c3
--- /dev/null
+++ b/src/components/Homepage/KPISection.tsx
@@ -0,0 +1,230 @@
+"use client"
+
+import { useEffect, useRef, useState } from "react"
+import { ArrowLeftRight, User } from "lucide-react"
+import { useIntersectionObserver } from "usehooks-ts"
+
+import { Section, SectionHeader, SectionTag } from "@/components/ui/section"
+
+import { cn } from "@/lib/utils/cn"
+
+type KPISectionProps = {
+ accountHolders: number | null
+ transactionsToday: number | null
+ className?: string
+}
+
+// ~2,914 transactions every 12 seconds across Ethereum + major L2s
+// Based on L2BEAT daily averages for Base, Arbitrum One, OP Mainnet, Starknet, Scroll, Linea, ZKsync Era
+const TRANSACTIONS_PER_INTERVAL = 2914
+const INTERVAL_MS = 12_000
+const ANIMATION_DURATION_MS = 2000
+
+/**
+ * Hook that returns an incrementing transaction count
+ * Adds ~2,914 transactions every 12 seconds when visible
+ * Returns null if initialValue is null (error state)
+ */
+function useIncrementalCounter(
+ initialValue: number | null,
+ isVisible: boolean
+): number | null {
+ const [target, setTarget] = useState(initialValue)
+
+ // Sync with new initial value when it changes
+ useEffect(() => {
+ setTarget(initialValue)
+ }, [initialValue])
+
+ // Increment counter every 12 seconds when visible (only if we have a valid value)
+ useEffect(() => {
+ if (!isVisible || initialValue === null) return
+
+ const interval = setInterval(() => {
+ setTarget((prev) =>
+ prev !== null ? prev + TRANSACTIONS_PER_INTERVAL : null
+ )
+ }, INTERVAL_MS)
+
+ return () => clearInterval(interval)
+ }, [isVisible, initialValue])
+
+ return target
+}
+
+/**
+ * AnimatedNumber component - animates smoothly to target value
+ * Respects prefers-reduced-motion
+ */
+function AnimatedNumber({
+ value,
+ formatter,
+ className,
+}: {
+ value: number
+ formatter: (n: number) => string
+ className?: string
+}) {
+ const [displayValue, setDisplayValue] = useState(value)
+ const animationRef = useRef(null)
+ const previousValue = useRef(value)
+
+ useEffect(() => {
+ const prefersReducedMotion = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ ).matches
+
+ // If reduced motion or first render, show value immediately
+ if (prefersReducedMotion || previousValue.current === value) {
+ setDisplayValue(value)
+ previousValue.current = value
+ return
+ }
+
+ // Cancel any ongoing animation
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current)
+ }
+
+ const startValue = displayValue
+ const startTime = performance.now()
+
+ const animate = (currentTime: number) => {
+ const elapsed = currentTime - startTime
+ const progress = Math.min(elapsed / ANIMATION_DURATION_MS, 1)
+
+ // Ease-out cubic
+ const easedProgress = 1 - Math.pow(1 - progress, 3)
+ const current = Math.floor(
+ startValue + (value - startValue) * easedProgress
+ )
+
+ setDisplayValue(current)
+
+ if (progress < 1) {
+ animationRef.current = requestAnimationFrame(animate)
+ } else {
+ animationRef.current = null
+ }
+ }
+
+ animationRef.current = requestAnimationFrame(animate)
+ previousValue.current = value
+
+ return () => {
+ if (animationRef.current) {
+ cancelAnimationFrame(animationRef.current)
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [value])
+
+ return {formatter(displayValue)}
+}
+
+/**
+ * Format large numbers with M/B suffix
+ */
+function formatNumber(value: number): string {
+ if (value >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(1)}B`
+ }
+ if (value >= 1_000_000) {
+ return `${Math.round(value / 1_000_000)}M`
+ }
+ if (value >= 1_000) {
+ return new Intl.NumberFormat("en-US").format(value)
+ }
+ return value.toString()
+}
+
+/**
+ * Format transaction count with spaces (European style: 21 400 433)
+ */
+function formatTransactions(value: number): string {
+ return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ")
+}
+
+const KPISection = ({
+ accountHolders,
+ transactionsToday,
+ className,
+}: KPISectionProps) => {
+ const { ref: intersectionRef, isIntersecting: isVisible } =
+ useIntersectionObserver({
+ threshold: 0.3,
+ freezeOnceVisible: true,
+ })
+
+ const liveTransactions = useIncrementalCounter(transactionsToday, isVisible)
+
+ return (
+
+
+
+ The user-owned internet
+
+
+ Ethereum gives back control of your assets
+
+
+
+
+ Your bank account is an entry in someone else's database. Your
+ application is a file in someone else's server. Ethereum is an
+ alternative network where you hold your assets directly.
+
+
+
+
+
+
+
+
+
+
+
+ {accountHolders !== null ? formatNumber(accountHolders) : "—"}
+
+
+ ETH holders
+
+
+
+
+
+
+
+ {liveTransactions !== null ? (
+
+ ) : (
+
—
+ )}
+
+ Transactions today
+
+
+
+
+
+
+ )
+}
+
+export default KPISection
diff --git a/src/components/Homepage/PersonaModalCTA.tsx b/src/components/Homepage/PersonaModalCTA.tsx
new file mode 100644
index 00000000000..149661506b7
--- /dev/null
+++ b/src/components/Homepage/PersonaModalCTA.tsx
@@ -0,0 +1,216 @@
+"use client"
+
+import { useRef, useState } from "react"
+import { BookOpen, Building2, Code, ExternalLink } from "lucide-react"
+
+import { ChevronNext } from "@/components/Chevron"
+import { Button } from "@/components/ui/buttons/Button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog-modal"
+import { BaseLink } from "@/components/ui/Link"
+
+import { cn } from "@/lib/utils/cn"
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
+
+type PersonaLink = {
+ label: string
+ href: string
+ isExternal?: boolean
+ eventName: string
+}
+
+type PersonaCategory = {
+ id: string
+ label: string
+ Icon: React.FC<{ className?: string }>
+ iconBgClass: string
+ iconColorClass: string
+ links: PersonaLink[]
+}
+
+const categories: PersonaCategory[] = [
+ {
+ id: "beginners",
+ label: "For beginners",
+ Icon: BookOpen,
+ iconBgClass: "bg-accent-a/20",
+ iconColorClass: "text-accent-a",
+ links: [
+ {
+ label: "What is Ethereum?",
+ href: "/what-is-ethereum/",
+ eventName: "learn_ethereum",
+ },
+ {
+ label: "Get a wallet",
+ href: "/wallets/find-wallet/",
+ eventName: "get_wallet",
+ },
+ ],
+ },
+ {
+ id: "developers",
+ label: "For developers",
+ Icon: Code,
+ iconBgClass: "bg-primary-low-contrast",
+ iconColorClass: "text-primary",
+ links: [
+ {
+ label: "Developer Hub",
+ href: "/developers/",
+ eventName: "developer_hub",
+ },
+ { label: "Docs", href: "/developers/docs/", eventName: "docs" },
+ ],
+ },
+ {
+ id: "enterprise",
+ label: "For enterprise",
+ Icon: Building2,
+ iconBgClass: "bg-accent-c/20",
+ iconColorClass: "text-accent-c",
+ links: [
+ { label: "Founders", href: "/founders/", eventName: "founders" },
+ {
+ label: "Institutions",
+ href: ENTERPRISE_ETHEREUM_URL,
+ isExternal: true,
+ eventName: "institutions",
+ },
+ ],
+ },
+]
+
+type PersonaModalCTAProps = {
+ eventCategory: string
+}
+
+const PersonaModalCTA = ({ eventCategory }: PersonaModalCTAProps) => {
+ const [isOpen, setIsOpen] = useState(false)
+ // Track if modal was closed via link click (not ESC/outside click/X button)
+ const closedViaLinkRef = useRef(false)
+
+ const handleOpenChange = (open: boolean) => {
+ if (open) {
+ // Modal is opening - fire both cta_click and modal_open for funnel analysis
+ closedViaLinkRef.current = false
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "cta_click",
+ eventName: "start_here",
+ })
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "modal_open",
+ eventName: "persona_modal",
+ })
+ } else if (!closedViaLinkRef.current) {
+ // Modal is closing without a link selection (ESC, click outside, X button)
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "modal_close",
+ eventName: "persona_modal",
+ })
+ }
+ setIsOpen(open)
+ }
+
+ const handleLinkClick = (eventName: string) => {
+ closedViaLinkRef.current = true
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "modal_select",
+ eventName,
+ })
+ setIsOpen(false)
+ }
+
+ return (
+
+
+
+ Start here
+
+
+
+
+
+
+ What brings you here?
+
+
+ Choose your path: resources for beginners, developers, or
+ enterprise.
+
+
+
+ {categories.map(
+ ({ id, label, Icon, iconBgClass, iconColorClass, links }) => (
+
+ {/* Icon and Category Label */}
+
+
+ {/* Links */}
+
+ {links.map(
+ (
+ { label: linkLabel, href, isExternal, eventName },
+ idx
+ ) => (
+
+ {idx > 0 &&
}
+
handleLinkClick(eventName)}
+ hideArrow
+ className="group flex items-center justify-between text-xl font-bold text-primary no-underline transition-colors hover:text-primary-hover md:text-3xl"
+ {...(isExternal && {
+ target: "_blank",
+ rel: "noopener noreferrer",
+ })}
+ >
+
+ {linkLabel}
+ {isExternal && (
+
+ )}
+
+
+
+
+ )
+ )}
+
+
+ )
+ )}
+
+
+
+ )
+}
+
+export default PersonaModalCTA
diff --git a/src/components/Homepage/SavingsCarousel.tsx b/src/components/Homepage/SavingsCarousel.tsx
new file mode 100644
index 00000000000..c596f294837
--- /dev/null
+++ b/src/components/Homepage/SavingsCarousel.tsx
@@ -0,0 +1,354 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { AnimationControls, motion, useAnimationControls } from "framer-motion"
+import type { Swiper as SwiperType } from "swiper"
+import { SwiperSlide } from "swiper/react"
+
+import { Image } from "@/components/Image"
+import Link from "@/components/ui/Link"
+import {
+ SectionContent,
+ SectionHeader,
+ SectionTag,
+} from "@/components/ui/section"
+import {
+ Swiper,
+ SwiperContainer,
+ SwiperNavigation,
+} from "@/components/ui/swiper"
+
+import { cn } from "@/lib/utils/cn"
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+import FloatingCard from "./FloatingCard"
+
+import borrowingImage from "@/public/images/homepage/savings/borrowing.png"
+import defiImage from "@/public/images/homepage/savings/defi.png"
+import remittancesImage from "@/public/images/homepage/savings/remittances.png"
+
+const APY_DATA = {
+ traditional: {
+ label: "Traditional Savings",
+ apy: 0.5,
+ },
+ ethereum: {
+ label: "Ethereum Apps",
+ apyMin: 4,
+ apyMax: 8,
+ },
+} as const
+
+type ComparisonItem = {
+ label: string
+ value: string
+ suffix?: string
+ smallText?: boolean
+}
+
+type ComparisonData = {
+ traditional: ComparisonItem
+ ethereum: ComparisonItem
+}
+
+type Slide = {
+ id: string
+ tag: string
+ title: string
+ subtitle: string
+ description: string
+ cta: string
+ href: string
+ image: typeof defiImage
+ comparison: ComparisonData | "apy"
+}
+
+const slides: Slide[] = [
+ {
+ id: "defi",
+ tag: "SAVINGS & INTEREST",
+ title: "Ownership has financial benefits too",
+ subtitle: "When there's no broker taking a cut, you gain more.",
+ description:
+ "Earn higher interest on funds using lending apps on Ethereum. You can withdraw your money 24/7.",
+ cta: "See DeFi →",
+ href: "/defi/",
+ image: defiImage,
+ comparison: "apy",
+ },
+ {
+ id: "remittances",
+ tag: "CROSS-BORDER PAYMENTS",
+ title: "Send money home in 12 minutes",
+ subtitle: "Skip the $50 wire fee and the 5+ day wait.",
+ description:
+ "Send stablecoins for just $0.2, and your family receives the funds almost instantly.",
+ cta: "Send money →",
+ href: "/payments/",
+ image: remittancesImage,
+ comparison: {
+ traditional: { label: "WIRE TRANSFER", value: "3-5 days" },
+ ethereum: { label: "ETHEREUM", value: "12 minutes" },
+ },
+ },
+ {
+ id: "borrowing",
+ tag: "FINANCIAL ACCESS",
+ title: "Borrow without credit history",
+ subtitle: "You don't need a credit score to get started.",
+ description:
+ "Using DeFi apps on Ethereum, you can provide collateral and access credit instantly, no permission required.",
+ cta: "Try it yourself →",
+ href: "/apps/categories/defi/",
+ image: borrowingImage,
+ comparison: {
+ traditional: {
+ label: "TRADITIONAL BANK",
+ value: "Credit checks",
+ smallText: true,
+ },
+ ethereum: {
+ label: "ON ETHEREUM",
+ value: "Based on collateral",
+ smallText: true,
+ },
+ },
+ },
+]
+
+const getComparison = (slide: Slide): ComparisonData => {
+ if (slide.comparison === "apy") {
+ return {
+ traditional: {
+ label: APY_DATA.traditional.label,
+ value: `${APY_DATA.traditional.apy}%`,
+ suffix: "APY",
+ },
+ ethereum: {
+ label: APY_DATA.ethereum.label,
+ value: `${APY_DATA.ethereum.apyMin}-${APY_DATA.ethereum.apyMax}%`,
+ suffix: "APY",
+ },
+ }
+ }
+ return slide.comparison
+}
+
+type SavingsCarouselProps = {
+ className?: string
+ eventCategory?: string
+}
+
+type ComparisonCardProps = {
+ item: ComparisonItem
+ variant: "default" | "primary"
+ controls: AnimationControls
+ initial: { opacity: number; x?: number; y: number }
+ transition: { duration: number; delay: number; ease: string }
+ className?: string
+}
+
+const ComparisonCard = ({
+ item,
+ variant,
+ controls,
+ initial,
+ transition,
+ className,
+}: ComparisonCardProps) => {
+ const isPrimary = variant === "primary"
+
+ return (
+
+
+
+ {item.label}
+
+
+
+ {item.value}
+
+ {item.suffix && (
+ {item.suffix}
+ )}
+
+
+
+ )
+}
+
+type SlideContentProps = {
+ slide: Slide
+ isActive: boolean
+ eventCategory: string
+}
+
+const SlideContent = ({
+ slide,
+ isActive,
+ eventCategory,
+}: SlideContentProps) => {
+ const comparison = getComparison(slide)
+ const traditionalControls = useAnimationControls()
+ const ethereumControls = useAnimationControls()
+
+ useEffect(() => {
+ if (isActive) {
+ // Mobile: animate Y only; Desktop: animate X and Y
+ traditionalControls.start({ opacity: 1, x: 0, y: 0 })
+ ethereumControls.start({ opacity: 1, x: 0, y: 0 })
+ } else {
+ // Desktop uses x offset, mobile doesn't (hidden via CSS anyway)
+ traditionalControls.set({ opacity: 0, x: -20, y: 10 })
+ ethereumControls.set({ opacity: 0, x: -30, y: 15 })
+ }
+ }, [isActive, traditionalControls, ethereumControls])
+
+ return (
+
+ {/* Content section - appears second on mobile, first on desktop */}
+
+
+ {slide.tag}
+
+ {slide.title}
+
+
+
+
+
{slide.subtitle}
+
{slide.description}
+
+
+
+ {slide.cta}
+
+
+ {/* Mobile comparison cards - stacked below content */}
+
+
+
+
+
+
+ {/* Image section - appears first on mobile, second on desktop */}
+
+ {/* Mobile: simple rounded image */}
+
+
+
+
+ {/* Desktop: image with overlapping comparison cards */}
+
+
+
+ )
+}
+
+const SavingsCarousel = ({
+ className,
+ eventCategory = "Homepage",
+}: SavingsCarouselProps) => {
+ const [activeIndex, setActiveIndex] = useState(0)
+
+ const handleSlideChange = (swiper: SwiperType) => {
+ setActiveIndex(swiper.activeIndex)
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "cta_swipe",
+ eventName: String(swiper.activeIndex + 1),
+ })
+ }
+
+ return (
+
+
+
+ {slides.map((slide, index) => (
+
+
+
+ ))}
+
+
+
+
+ )
+}
+
+export default SavingsCarousel
diff --git a/src/components/Homepage/SimulatorSection/SimulatorI18nWrapper.tsx b/src/components/Homepage/SimulatorSection/SimulatorI18nWrapper.tsx
new file mode 100644
index 00000000000..7ae01b5aefd
--- /dev/null
+++ b/src/components/Homepage/SimulatorSection/SimulatorI18nWrapper.tsx
@@ -0,0 +1,32 @@
+"use client"
+
+import { NextIntlClientProvider } from "next-intl"
+
+/**
+ * Minimal glossary translations for the SimulatorSection.
+ *
+ * The homepage is English-only, so we hardcode just the required translations
+ * instead of loading the full glossary-tooltip namespace.
+ */
+const SIMULATOR_MESSAGES = {
+ "glossary-tooltip": {
+ "nft-term": "Non-fungible token (NFT)",
+ "nft-definition":
+ 'A unique digital item you can own, like art or collectibles, verified by blockchain technology. More on non-fungible tokens (NFTs) .',
+ "web3-term": "Web3",
+ "web3-definition":
+ 'Web3 is the new internet with blockchain, where users control their data and transactions, not companies. No need to share any personal information. More on web3 .',
+ },
+}
+
+export function SimulatorI18nWrapper({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/Homepage/SimulatorSection/index.tsx b/src/components/Homepage/SimulatorSection/index.tsx
new file mode 100644
index 00000000000..b117ed55668
--- /dev/null
+++ b/src/components/Homepage/SimulatorSection/index.tsx
@@ -0,0 +1,100 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import { useIntersectionObserver } from "usehooks-ts"
+
+import { SEND_RECEIVE } from "@/components/Simulator/constants"
+import { Explanation } from "@/components/Simulator/Explanation"
+import type { SimulatorNav } from "@/components/Simulator/interfaces"
+import { Phone } from "@/components/Simulator/Phone"
+import { Template } from "@/components/Simulator/Template"
+import { Section, SectionHeader, SectionTag } from "@/components/ui/section"
+
+import { cn } from "@/lib/utils/cn"
+
+import { walletOnboardingSimData } from "@/data/WalletSimulatorData"
+
+type SimulatorSectionProps = {
+ className?: string
+}
+
+/**
+ * Loading skeleton that matches simulator phone dimensions
+ */
+const SimulatorSkeleton = () => (
+
+)
+
+const sendReceiveData = walletOnboardingSimData[SEND_RECEIVE]
+
+const SimulatorSection = ({ className }: SimulatorSectionProps) => {
+ const { ref: sectionRef, isIntersecting: isVisible } =
+ useIntersectionObserver({
+ rootMargin: "200px",
+ freezeOnceVisible: true,
+ })
+ const [isLoaded, setIsLoaded] = useState(false)
+ const [step, setStep] = useState(0)
+
+ const { Screen, explanations, ctaLabels, finalCtaLink } = sendReceiveData
+ const totalSteps = explanations.length
+ const explanation = explanations[step]
+ const ctaLabel = ctaLabels[step]
+
+ const nav: SimulatorNav = {
+ step,
+ totalSteps,
+ progressStepper: () => setStep((s) => Math.min(s + 1, totalSteps - 1)),
+ regressStepper: () => setStep((s) => Math.max(s - 1, 0)),
+ openPath: () => {},
+ }
+
+ useEffect(() => {
+ if (isVisible) {
+ const timer = setTimeout(() => setIsLoaded(true), 300)
+ return () => clearTimeout(timer)
+ }
+ }, [isVisible])
+
+ return (
+
+
+
Free forever
+
+ Try Ethereum in your browser
+
+
+ Experience how Ethereum works. Just click and explore.
+
+
+
+
+ {!isVisible || !isLoaded ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+ )}
+
+
+ )
+}
+
+export default SimulatorSection
diff --git a/src/components/Homepage/TrustLogos.tsx b/src/components/Homepage/TrustLogos.tsx
new file mode 100644
index 00000000000..27ee4db37dc
--- /dev/null
+++ b/src/components/Homepage/TrustLogos.tsx
@@ -0,0 +1,141 @@
+import { ArrowRight, Check } from "lucide-react"
+import type { StaticImageData } from "next/image"
+
+import { Image } from "@/components/Image"
+import { BaseLink } from "@/components/ui/Link"
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+ SectionTag,
+} from "@/components/ui/section"
+
+import { cn } from "@/lib/utils/cn"
+
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
+
+import FloatingCard from "./FloatingCard"
+
+import builtToLastImage from "@/public/images/homepage/built-to-last.png"
+import blackrockLogo from "@/public/images/homepage/logos/blackrock.webp"
+import jpmorganLogo from "@/public/images/homepage/logos/jpmorgan.png"
+import mastercardLogo from "@/public/images/homepage/logos/mastercard.png"
+import paypalLogo from "@/public/images/homepage/logos/paypal.png"
+import robinhoodLogo from "@/public/images/homepage/logos/robinhood.png"
+import visaLogo from "@/public/images/homepage/logos/visa.png"
+
+type Logo = {
+ src: StaticImageData
+ alt: string
+ className?: string
+}
+
+const logos: Logo[] = [
+ { src: mastercardLogo, alt: "Mastercard", className: "h-10" },
+ { src: visaLogo, alt: "Visa" },
+ { src: jpmorganLogo, alt: "JPMorgan" },
+ { src: robinhoodLogo, alt: "Robinhood" },
+ { src: paypalLogo, alt: "PayPal" },
+ { src: blackrockLogo, alt: "BlackRock" },
+]
+
+type TrustLogosProps = {
+ className?: string
+ eventCategory?: string
+}
+
+const TrustLogos = ({
+ className,
+ eventCategory = "Homepage",
+}: TrustLogosProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+ Never offline
+
+
+
+
+ 100% uptime
+
+
+
+
+
+
+ 10 years
+
+
+ Since 2015
+
+
+
+
+
+
+
+
+ Trusted by leading institutions
+
+ Built to last
+
+
+
+ Major financial institutions choose Ethereum because it's the
+ most battle-tested, low-risk, and dependable blockchain. The code is
+ open, the network is always on, and the track record speaks for
+ itself.
+
+
+
+ See institutional adoption
+
+
+
+
+ {logos.map((logo) => (
+
+
+
+ ))}
+
+
+
+ )
+}
+
+export default TrustLogos
diff --git a/src/components/Matomo.tsx b/src/components/Matomo.tsx
index 9136e31f294..f316352ddbb 100644
--- a/src/components/Matomo.tsx
+++ b/src/components/Matomo.tsx
@@ -4,28 +4,34 @@ import { useEffect, useState } from "react"
import { usePathname } from "next/navigation"
import { init, push } from "@socialgouv/matomo-next"
-import { IS_PREVIEW_DEPLOY } from "@/lib/utils/env"
+// Module-level flag to prevent double initialization in React Strict Mode
+let matomoInitialized = false
export default function Matomo() {
const pathname = usePathname()
- const [inited, setInited] = useState(false)
const [previousPath, setPreviousPath] = useState("")
useEffect(() => {
- if (!IS_PREVIEW_DEPLOY && !inited) {
+ if (!matomoInitialized) {
init({
url: process.env.NEXT_PUBLIC_MATOMO_URL!,
siteId: process.env.NEXT_PUBLIC_MATOMO_SITE_ID!,
})
+ // Use sendBeacon API for reliable tracking during page navigation
+ // Without this, click events on internal links are lost due to race
+ // conditions with Next.js client-side routing
+ // See: https://matomo.org/faq/how-to/faq_33087/
+ push(["alwaysUseSendBeacon"])
+
console.log(
"[Matomo] initialized with URL:",
process.env.NEXT_PUBLIC_MATOMO_URL
)
- setInited(true)
+ matomoInitialized = true
}
- }, [inited])
+ }, [])
/**
* The @socialgouv/matomo-next does not work with next 13
diff --git a/src/components/ScrollDepthTracker.tsx b/src/components/ScrollDepthTracker.tsx
new file mode 100644
index 00000000000..33881314e98
--- /dev/null
+++ b/src/components/ScrollDepthTracker.tsx
@@ -0,0 +1,72 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+const THRESHOLDS = [25, 50, 75, 100] as const
+
+interface ScrollDepthTrackerProps {
+ eventCategory: string
+}
+
+export default function ScrollDepthTracker({
+ eventCategory,
+}: ScrollDepthTrackerProps) {
+ const firedThresholds = useRef>(new Set())
+
+ useEffect(() => {
+ let ticking = false
+ let rafId: number | null = null
+
+ const cleanup = () => {
+ window.removeEventListener("scroll", throttledHandler)
+ if (rafId !== null) {
+ window.cancelAnimationFrame(rafId)
+ }
+ }
+
+ const handleScroll = () => {
+ const scrollTop = window.scrollY
+ const docHeight = document.documentElement.scrollHeight
+ const viewportHeight = window.innerHeight
+ const scrollPercent = ((scrollTop + viewportHeight) / docHeight) * 100
+
+ for (const threshold of THRESHOLDS) {
+ if (
+ scrollPercent >= threshold &&
+ !firedThresholds.current.has(threshold)
+ ) {
+ firedThresholds.current.add(threshold)
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "scroll_depth",
+ eventName: `${threshold}%`,
+ })
+ }
+ }
+
+ // Remove listener once all thresholds have been tracked
+ if (firedThresholds.current.size === THRESHOLDS.length) {
+ cleanup()
+ }
+ }
+
+ const throttledHandler = () => {
+ if (!ticking) {
+ rafId = window.requestAnimationFrame(() => {
+ handleScroll()
+ ticking = false
+ })
+ ticking = true
+ }
+ }
+
+ window.addEventListener("scroll", throttledHandler, { passive: true })
+ handleScroll() // Check initial scroll position
+
+ return cleanup
+ }, [eventCategory])
+
+ return null
+}
diff --git a/src/components/TrackedSection.tsx b/src/components/TrackedSection.tsx
new file mode 100644
index 00000000000..b021ab180e1
--- /dev/null
+++ b/src/components/TrackedSection.tsx
@@ -0,0 +1,54 @@
+"use client"
+
+import { useEffect, useRef } from "react"
+import { useIntersectionObserver } from "usehooks-ts"
+import { Slot } from "@radix-ui/react-slot"
+
+import { trackCustomEvent } from "@/lib/utils/matomo"
+
+type TrackedSectionProps = {
+ id: string
+ eventCategory: string
+ children: React.ReactNode
+ asChild?: boolean
+}
+
+export function TrackedSection({
+ id,
+ eventCategory,
+ children,
+ asChild = false,
+}: TrackedSectionProps) {
+ const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.3 })
+ const hasTrackedView = useRef(false)
+ const timerRef = useRef(null)
+
+ // Track section_view after 1 second of visibility
+ useEffect(() => {
+ if (isIntersecting && !hasTrackedView.current) {
+ timerRef.current = setTimeout(() => {
+ trackCustomEvent({
+ eventCategory,
+ eventAction: "section_view",
+ eventName: id,
+ })
+ hasTrackedView.current = true
+ }, 1000)
+ } else if (!isIntersecting && timerRef.current) {
+ clearTimeout(timerRef.current)
+ timerRef.current = null
+ }
+
+ return () => {
+ if (timerRef.current) clearTimeout(timerRef.current)
+ }
+ }, [isIntersecting, eventCategory, id])
+
+ const Comp = asChild ? Slot : "div"
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/ui/link-box.tsx b/src/components/ui/link-box.tsx
index 75db189490c..80eb7ab21cb 100644
--- a/src/components/ui/link-box.tsx
+++ b/src/components/ui/link-box.tsx
@@ -40,7 +40,9 @@ const LinkOverlay = forwardRef(
matomoEvent && trackCustomEvent(matomoEvent)}
diff --git a/src/components/ui/section.tsx b/src/components/ui/section.tsx
index 40c7a796623..bc43d444ce1 100644
--- a/src/components/ui/section.tsx
+++ b/src/components/ui/section.tsx
@@ -59,16 +59,23 @@ const SectionHeader = React.forwardRef<
))
SectionHeader.displayName = "SectionHeader"
+const tagVariants = cva("w-fit text-sm uppercase", {
+ variants: {
+ variant: {
+ pill: "rounded-full bg-primary-low-contrast px-4 py-0.5 text-primary",
+ plain: "font-semibold tracking-wider text-primary-high-contrast",
+ },
+ },
+ defaultVariants: { variant: "pill" },
+})
+
const SectionTag = React.forwardRef<
HTMLDivElement,
- React.HTMLAttributes
->(({ className, ...props }, ref) => (
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
))
diff --git a/src/data-layer/fetchers/fetchAccountHolders.ts b/src/data-layer/fetchers/fetchAccountHolders.ts
new file mode 100644
index 00000000000..99a6f8ef01c
--- /dev/null
+++ b/src/data-layer/fetchers/fetchAccountHolders.ts
@@ -0,0 +1,61 @@
+import type { MetricReturnData } from "@/lib/types"
+
+import { DUNE_API_URL } from "@/lib/constants"
+
+export const FETCH_ACCOUNT_HOLDERS_TASK_ID = "fetch-account-holders"
+
+// Dune query: https://dune.com/queries/6676254
+// "Ethereum Cumulative Unique Addresses" - Total count of unique addresses
+// that have ever sent or received a transaction on Ethereum mainnet.
+const DUNE_QUERY_ID = "6676254"
+
+type AccountHoldersResponse = {
+ result: {
+ rows: Array<{ unique_addresses: number }>
+ }
+}
+
+/**
+ * Fetch cumulative unique Ethereum addresses from Dune Analytics API.
+ * Returns the total count of addresses that have ever transacted on mainnet.
+ */
+export async function fetchAccountHolders(): Promise {
+ const duneApiKey = process.env.DUNE_API_KEY
+
+ if (!duneApiKey) {
+ throw new Error("Dune API key not found (DUNE_API_KEY)")
+ }
+
+ const url = new URL(`api/v1/query/${DUNE_QUERY_ID}/results`, DUNE_API_URL)
+
+ console.log("Starting account holders data fetch from Dune Analytics")
+
+ const response = await fetch(url, {
+ headers: { "X-Dune-API-Key": duneApiKey },
+ })
+
+ if (!response.ok) {
+ const status = response.status
+ console.warn("Dune Analytics fetch non-OK", { status, url: url.toString() })
+ throw new Error(`Dune Analytics API responded with status ${status}`)
+ }
+
+ const json: AccountHoldersResponse = await response.json()
+ const {
+ result: { rows = [] },
+ } = json
+
+ if (rows.length === 0) {
+ throw new Error("No data returned from Dune Analytics query")
+ }
+
+ const value = rows[0].unique_addresses
+ const timestamp = Date.now()
+
+ console.log("Successfully fetched account holders data", {
+ value,
+ timestamp,
+ })
+
+ return { value, timestamp }
+}
diff --git a/src/data-layer/index.ts b/src/data-layer/index.ts
index ad42e32b925..741fbd18ffb 100644
--- a/src/data-layer/index.ts
+++ b/src/data-layer/index.ts
@@ -45,3 +45,4 @@ export const getTotalEthStakedData = () => get(KEYS.TOTAL_ETH_
export const getTotalValueLockedData = () => get(KEYS.TOTAL_VALUE_LOCKED)
export const getEventsData = () => get(KEYS.EVENTS)
export const getDeveloperToolsData = () => get(KEYS.DEVELOPER_TOOLS)
+export const getAccountHolders = () => get(KEYS.ACCOUNT_HOLDERS)
diff --git a/src/data-layer/mocks/fetch-account-holders.json b/src/data-layer/mocks/fetch-account-holders.json
new file mode 100644
index 00000000000..2cbc5d8688c
--- /dev/null
+++ b/src/data-layer/mocks/fetch-account-holders.json
@@ -0,0 +1,4 @@
+{
+ "value": 292141996,
+ "timestamp": 1739097600000
+}
diff --git a/src/data-layer/mocks/index.ts b/src/data-layer/mocks/index.ts
index f7e9e784f50..87238eb6372 100644
--- a/src/data-layer/mocks/index.ts
+++ b/src/data-layer/mocks/index.ts
@@ -5,10 +5,11 @@
* for local development without needing to connect to Netlify Blobs.
*
* Generated: 2025-12-16T18:32:05.983Z
- * Total files: 20
+ * Total files: 22
*/
export const mockTaskIds = [
+ "fetch-account-holders",
"fetch-apps",
"fetch-beaconchain",
"fetch-events",
diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts
index 8f44950be95..3217df6b95b 100644
--- a/src/data-layer/tasks.ts
+++ b/src/data-layer/tasks.ts
@@ -8,6 +8,7 @@
import { schedules, task, tasks } from "@trigger.dev/sdk/v3"
import { fetchDeveloperTools } from "./fetchers/developer-tools"
+import { fetchAccountHolders } from "./fetchers/fetchAccountHolders"
import { fetchApps } from "./fetchers/fetchApps"
import { fetchBeaconChain } from "./fetchers/fetchBeaconChain"
import { fetchBlobscanStats } from "./fetchers/fetchBlobscanStats"
@@ -54,12 +55,14 @@ export const KEYS = {
TOTAL_ETH_STAKED: "fetch-total-eth-staked",
TOTAL_VALUE_LOCKED: "fetch-total-value-locked",
STABLECOINS_DATA: "fetch-stablecoins-data",
+ ACCOUNT_HOLDERS: "fetch-account-holders",
} as const
// Task definition: storage key + fetch function
type TaskDef = [string, () => Promise]
const DAILY: TaskDef[] = [
+ [KEYS.ACCOUNT_HOLDERS, fetchAccountHolders],
[KEYS.APPS, fetchApps],
[KEYS.CALENDAR_EVENTS, fetchCalendarEvents],
[KEYS.COMMUNITY_PICKS, fetchCommunityPicks],
diff --git a/src/lib/ab-testing/server.ts b/src/lib/ab-testing/server.ts
index 34d62e6d9d4..004c7be7fd2 100644
--- a/src/lib/ab-testing/server.ts
+++ b/src/lib/ab-testing/server.ts
@@ -1,8 +1,16 @@
+import { SITE_URL } from "@/lib/constants"
+
import type { ABTestAssignment, ABTestConfig } from "./types"
+// Search engine and social media crawlers - serve Original to ensure consistent
+// indexing and link previews. This is NOT cloaking per Google's A/B testing guidelines:
+// https://developers.google.com/search/docs/advanced/guidelines/cloaking
+const BOT_PATTERN =
+ /googlebot|bingbot|yandex|baiduspider|duckduckbot|slurp|facebookexternalhit|twitterbot|linkedinbot|discordbot|telegrambot|whatsapp|slackbot/i
+
const getABTestConfigs = async (): Promise> => {
try {
- const response = await fetch("https://ethereum.org/api/ab-config", {
+ const response = await fetch(`${SITE_URL}/api/ab-config`, {
next: { revalidate: 3600 },
})
@@ -30,6 +38,10 @@ export const getABTestAssignment = async (
headers.get("x-forwarded-for") || headers.get("x-real-ip") || "unknown"
const userAgent = headers.get("user-agent") || ""
+ // Always serve Original to bots to prevent indexing fluctuation during A/B tests
+ // and ensure consistent social media link previews
+ if (BOT_PATTERN.test(userAgent)) return null
+
// Add privacy-preserving entropy sources
const acceptLanguage = headers.get("accept-language") || ""
const acceptEncoding = headers.get("accept-encoding") || ""
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index f1a39a1d69e..a25268d19bc 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -24,10 +24,15 @@ export const LOCALES_CODES = BUILD_LOCALES
? BUILD_LOCALES.split(",")
: i18nConfig.map(({ code }) => code)
-// Site urls
+// Site urls - auto-detect from Netlify deploy context
export const SITE_URL =
- process.env.NEXT_PUBLIC_SITE_URL || "https://ethereum.org"
+ process.env.NEXT_PUBLIC_SITE_URL ||
+ process.env.DEPLOY_PRIME_URL || // Branch/PR deploys
+ process.env.DEPLOY_URL || // Unique deploy URL
+ process.env.URL || // Primary site URL
+ "https://ethereum.org"
export const DISCORD_PATH = "https://discord.gg/ethereum-org/"
+export const ENTERPRISE_ETHEREUM_URL = "https://institutions.ethereum.org/"
export const GITHUB_REPO_URL =
"https://github.com/ethereum/ethereum-org-website/"
export const EDIT_CONTENT_URL = `https://github.com/ethereum/ethereum-org-website/tree/dev/`
diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts
index 24aece5ef0f..fc029ef4918 100644
--- a/src/lib/data/index.ts
+++ b/src/lib/data/index.ts
@@ -148,3 +148,9 @@ export const getDeveloperToolsData = createCachedGetter(
["developer-tools-data"],
CACHE_REVALIDATE_DAY
)
+
+export const getAccountHolders = createCachedGetter(
+ dataLayer.getAccountHolders,
+ ["account-holders"],
+ CACHE_REVALIDATE_DAY
+)
diff --git a/src/lib/nav/buildNavigation.ts b/src/lib/nav/buildNavigation.ts
index c1279bd4e53..4015cc217a1 100644
--- a/src/lib/nav/buildNavigation.ts
+++ b/src/lib/nav/buildNavigation.ts
@@ -1,5 +1,7 @@
import type { NavSections } from "@/components/Nav/types"
+import { ENTERPRISE_ETHEREUM_URL } from "@/lib/constants"
+
type TranslateFn = (key: string) => string
export const buildNavigation = (t: TranslateFn): NavSections => {
@@ -366,7 +368,7 @@ export const buildNavigation = (t: TranslateFn): NavSections => {
{
label: t("enterprise"),
description: t("nav-enterprise-description"),
- href: "/enterprise/",
+ href: ENTERPRISE_ETHEREUM_URL,
},
{
label: t("founders"),