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 */} + +
- {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 && ( -
- -
- 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 ( +
+
+ + + + + {alt} + +
+ +
+
+ + +
+

+ 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 */} + eth logo +
+ +

+ 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 ( + + + + + + + + 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 */} +
+
+ +
+

+ {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 ( +
+
+
+
+ Ethereum community illustration +
+ + +

+ 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) => ( +
+ {logo.alt} +
+ ))} +
+
+
+ ) +} + +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"),