diff --git a/app/[locale]/home-2026/page.tsx b/app/[locale]/home-2026/page.tsx deleted file mode 100644 index 784a36d83c4..00000000000 --- a/app/[locale]/home-2026/page.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { notFound } from "next/navigation" -import { getTranslations, setRequestLocale } from "next-intl/server" - -import type { Lang, PageParams } from "@/lib/types" - -import Homepage2026 from "@/components/Homepage/Homepage2026" - -import { getMetadata } from "@/lib/utils/metadata" - -import { LOCALES_CODES } from "@/lib/constants" - -import { getAccountHolders, getGrowThePieData } from "@/lib/data" - -const Page = async ({ params }: { params: PageParams }) => { - const { locale } = params - - if (!LOCALES_CODES.includes(locale)) return notFound() - - setRequestLocale(locale) - - const [growThePieData, accountHolders] = await Promise.all([ - getGrowThePieData(), - getAccountHolders(), - ]) - - // Handle null cases - throw error if required data is missing - if (!growThePieData) { - throw new Error("Failed to fetch GrowThePie data") - } - if (!accountHolders || "error" in accountHolders) { - throw new Error("Failed to fetch account holders data") - } - - const accountHoldersValue = accountHolders.value - const transactionsToday = - "value" in growThePieData.txCount ? growThePieData.txCount.value : 0 - - return ( - - ) -} - -export default Page - -export async function generateMetadata({ - params, -}: { - params: { locale: string } -}) { - const { locale } = params - - const t = await getTranslations({ locale, namespace: "page-index" }) - return getMetadata({ - locale, - slug: ["home-2026"], - title: t("page-index-meta-title"), - description: t("page-index-meta-description"), - }) -} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index a48003ed660..8bf9c76f570 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, @@ -79,6 +84,7 @@ import IndexPageJsonLD from "./page-jsonld" import { getActivity } from "./utils" import { + getAccountHolders, getAppsData, getAttestantPosts, getBeaconchainData, @@ -90,6 +96,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 +145,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 +156,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 +262,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 +270,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 +281,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 +289,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 +477,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/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/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/Hero/HomeHero2026/index.tsx b/src/components/Hero/HomeHero2026/index.tsx index a926a22a714..06ded61ee13 100644 --- a/src/components/Hero/HomeHero2026/index.tsx +++ b/src/components/Hero/HomeHero2026/index.tsx @@ -1,9 +1,17 @@ +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" @@ -11,17 +19,58 @@ 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 @@ -46,7 +95,7 @@ const HomeHero2026 = ({ } = getImageProps({ ...common, ...baseImage, quality: 5 }) return ( -
+
- + {ctaVariant === "modal" ? ( + + ) : ( +
+ {directButtonCTAs.map( + ({ + label, + description, + href, + className: ctaClass, + Svg, + eventName, + }) => { + const Link = ( + props: Omit< + SvgButtonLinkProps, + "Svg" | "href" | "label" | "children" + > + ) => ( + +

{description}

+
+ ) + return ( + + + + + ) + } + )} +
+ )}
- + ) } 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 index ace87660aa8..14b1d59f575 100644 --- a/src/components/Homepage/FeatureCards.tsx +++ b/src/components/Homepage/FeatureCards.tsx @@ -12,9 +12,13 @@ import publicRulesImage from "@/public/images/homepage/features/public-rules.png type FeatureCardsProps = { className?: string + eventCategory?: string } -const FeatureCards = ({ className }: FeatureCardsProps) => { +const FeatureCards = ({ + className, + eventCategory = "Homepage", +}: FeatureCardsProps) => { return (
{
- - Learn more + + What is Ethereum?
diff --git a/src/components/Homepage/GetStartedGrid.tsx b/src/components/Homepage/GetStartedGrid.tsx index 127fb2c5eea..af39c7abebe 100644 --- a/src/components/Homepage/GetStartedGrid.tsx +++ b/src/components/Homepage/GetStartedGrid.tsx @@ -13,6 +13,7 @@ import learnImage from "@/public/images/homepage/get-started/learn.png" const cards = [ { + id: "learn", icon: Book, iconBg: "bg-[#f7ecff]", iconColor: "text-primary", @@ -30,6 +31,7 @@ const cards = [ image: learnImage, }, { + id: "developers", icon: Code, iconBg: "bg-[#e9f4ff]", iconColor: "text-accent-a", @@ -47,6 +49,7 @@ const cards = [ image: developersImage, }, { + id: "enterprise", icon: Building2, iconBg: "bg-[#e6f7f6]", iconColor: "text-accent-c", @@ -67,9 +70,13 @@ const cards = [ type GetStartedGridProps = { className?: string + eventCategory?: string } -const GetStartedGrid = ({ className }: GetStartedGridProps) => { +const GetStartedGrid = ({ + className, + eventCategory = "Homepage", +}: GetStartedGridProps) => { return (
@@ -137,6 +144,11 @@ const GetStartedGrid = ({ className }: GetStartedGridProps) => { {card.cta} diff --git a/src/components/Homepage/Homepage2026.tsx b/src/components/Homepage/Homepage2026.tsx index 2606f61e357..59a06d9c6e5 100644 --- a/src/components/Homepage/Homepage2026.tsx +++ b/src/components/Homepage/Homepage2026.tsx @@ -3,12 +3,13 @@ import dynamic from "next/dynamic" import type { Lang } from "@/lib/types" -import HomeHero2026 from "@/components/Hero/HomeHero2026" +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" @@ -30,45 +31,61 @@ const SectionSkeleton = ({ className }: { className?: string }) => ( type Homepage2026Props = { locale: Lang - accountHolders: number - transactionsToday: number + 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 ( - +
- }> - - + + }> + + + - }> - - + + }> + + + - + + + - + + + - }> - - - - + + }> + + + + + - + + +
) diff --git a/src/components/Homepage/KPISection.tsx b/src/components/Homepage/KPISection.tsx index 67585509022..65c56be92c3 100644 --- a/src/components/Homepage/KPISection.tsx +++ b/src/components/Homepage/KPISection.tsx @@ -9,8 +9,8 @@ import { Section, SectionHeader, SectionTag } from "@/components/ui/section" import { cn } from "@/lib/utils/cn" type KPISectionProps = { - accountHolders: number - transactionsToday: number + accountHolders: number | null + transactionsToday: number | null className?: string } @@ -23,8 +23,12 @@ 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, isVisible: boolean) { +function useIncrementalCounter( + initialValue: number | null, + isVisible: boolean +): number | null { const [target, setTarget] = useState(initialValue) // Sync with new initial value when it changes @@ -32,16 +36,18 @@ function useIncrementalCounter(initialValue: number, isVisible: boolean) { setTarget(initialValue) }, [initialValue]) - // Increment counter every 12 seconds when visible + // Increment counter every 12 seconds when visible (only if we have a valid value) useEffect(() => { - if (!isVisible) return + if (!isVisible || initialValue === null) return const interval = setInterval(() => { - setTarget((prev) => prev + TRANSACTIONS_PER_INTERVAL) + setTarget((prev) => + prev !== null ? prev + TRANSACTIONS_PER_INTERVAL : null + ) }, INTERVAL_MS) return () => clearInterval(interval) - }, [isVisible]) + }, [isVisible, initialValue]) return target } @@ -144,7 +150,7 @@ const KPISection = ({ transactionsToday, className, }: KPISectionProps) => { - const { ref: sectionRef, isIntersecting: isVisible } = + const { ref: intersectionRef, isIntersecting: isVisible } = useIntersectionObserver({ threshold: 0.3, freezeOnceVisible: true, @@ -154,7 +160,7 @@ const KPISection = ({ return (

- {formatNumber(accountHolders)} + {accountHolders !== null ? formatNumber(accountHolders) : "—"}

ETH holders @@ -201,11 +207,15 @@ const KPISection = ({ strokeWidth={1.5} />

- + {liveTransactions !== null ? ( + + ) : ( +

+ )}

Transactions today

diff --git a/src/components/Homepage/PersonaModalCTA.tsx b/src/components/Homepage/PersonaModalCTA.tsx index aedc5b6a994..3a7ad3ebe12 100644 --- a/src/components/Homepage/PersonaModalCTA.tsx +++ b/src/components/Homepage/PersonaModalCTA.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useRef, useState } from "react" import { BookOpen, Building2, Code, ExternalLink } from "lucide-react" import { ChevronNext } from "@/components/Chevron" @@ -8,6 +8,7 @@ import { Button } from "@/components/ui/buttons/Button" import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogTrigger, @@ -21,6 +22,7 @@ type PersonaLink = { label: string href: string isExternal?: boolean + eventName: string } type PersonaCategory = { @@ -40,8 +42,16 @@ const categories: PersonaCategory[] = [ iconBgClass: "bg-accent-a/20", iconColorClass: "text-accent-a", links: [ - { label: "What is Ethereum?", href: "/what-is-ethereum/" }, - { label: "Get a wallet", href: "/wallets/find-wallet/" }, + { + label: "What is Ethereum?", + href: "/what-is-ethereum/", + eventName: "learn_ethereum", + }, + { + label: "Get a wallet", + href: "/wallets/find-wallet/", + eventName: "get_wallet", + }, ], }, { @@ -51,8 +61,12 @@ const categories: PersonaCategory[] = [ iconBgClass: "bg-primary-low-contrast", iconColorClass: "text-primary", links: [ - { label: "Developer Hub", href: "/developers/" }, - { label: "Docs", href: "/developers/docs/" }, + { + label: "Developer Hub", + href: "/developers/", + eventName: "developer_hub", + }, + { label: "Docs", href: "/developers/docs/", eventName: "docs" }, ], }, { @@ -62,11 +76,12 @@ const categories: PersonaCategory[] = [ iconBgClass: "bg-accent-c/20", iconColorClass: "text-accent-c", links: [ - { label: "Founders", href: "/founders/" }, + { label: "Founders", href: "/founders/", eventName: "founders" }, { label: "Institutions", href: "https://institutions.ethereum.org/", isExternal: true, + eventName: "institutions", }, ], }, @@ -78,23 +93,40 @@ type PersonaModalCTAProps = { 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: "start here", - eventName: "start here", + eventAction: "modal_close", + eventName: "persona_modal", }) } setIsOpen(open) } - const handleLinkClick = (label: string) => { + const handleLinkClick = (eventName: string) => { + closedViaLinkRef.current = true trackCustomEvent({ eventCategory, - eventAction: "modal", - eventName: label, + eventAction: "modal_select", + eventName, }) setIsOpen(false) } @@ -112,6 +144,10 @@ const PersonaModalCTA = ({ eventCategory }: PersonaModalCTAProps) => { What brings you here? + + Choose your path: resources for beginners, developers, or + enterprise. +
{categories.map( @@ -137,29 +173,34 @@ const PersonaModalCTA = ({ eventCategory }: PersonaModalCTAProps) => { {/* Links */}
- {links.map(({ label: linkLabel, href, isExternal }, idx) => ( -
- {idx > 0 &&
} - handleLinkClick(linkLabel)} - 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 && ( - - )} - - - -
- ))} + {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 && ( + + )} + + + +
+ ) + )}
) diff --git a/src/components/Homepage/SavingsCarousel.tsx b/src/components/Homepage/SavingsCarousel.tsx index 67467e60232..cb03b7314cd 100644 --- a/src/components/Homepage/SavingsCarousel.tsx +++ b/src/components/Homepage/SavingsCarousel.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/swiper" import { cn } from "@/lib/utils/cn" +import { trackCustomEvent } from "@/lib/utils/matomo" import FloatingCard from "./FloatingCard" @@ -135,6 +136,7 @@ const getComparison = (slide: Slide): ComparisonData => { type SavingsCarouselProps = { className?: string + eventCategory?: string } type ComparisonCardProps = { @@ -193,9 +195,14 @@ const ComparisonCard = ({ type SlideContentProps = { slide: Slide isActive: boolean + eventCategory: string } -const SlideContent = ({ slide, isActive }: SlideContentProps) => { +const SlideContent = ({ + slide, + isActive, + eventCategory, +}: SlideContentProps) => { const comparison = getComparison(slide) const traditionalControls = useAnimationControls() const ethereumControls = useAnimationControls() @@ -228,7 +235,15 @@ const SlideContent = ({ slide, isActive }: SlideContentProps) => {

{slide.description}

- + {slide.cta} @@ -297,15 +312,23 @@ const SlideContent = ({ slide, isActive }: SlideContentProps) => { ) } -const SavingsCarousel = ({ className }: SavingsCarouselProps) => { +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) => ( - + ))} -
+
) } diff --git a/src/components/Homepage/TrustLogos.tsx b/src/components/Homepage/TrustLogos.tsx index c70479b8b33..293332a7af0 100644 --- a/src/components/Homepage/TrustLogos.tsx +++ b/src/components/Homepage/TrustLogos.tsx @@ -38,9 +38,13 @@ const logos: Logo[] = [ type TrustLogosProps = { className?: string + eventCategory?: string } -const TrustLogos = ({ className }: TrustLogosProps) => { +const TrustLogos = ({ + className, + eventCategory = "Homepage", +}: TrustLogosProps) => { return (
{ See Institutional adoption diff --git a/src/components/Matomo.tsx b/src/components/Matomo.tsx index 9136e31f294..2f6a346b0df 100644 --- a/src/components/Matomo.tsx +++ b/src/components/Matomo.tsx @@ -4,16 +4,16 @@ 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!, @@ -23,9 +23,9 @@ export default function Matomo() { "[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/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..908da2d2dda 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -24,9 +24,13 @@ 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 GITHUB_REPO_URL = "https://github.com/ethereum/ethereum-org-website/"