diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9afbc926c06..f6c945e5f27 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,8 +5,8 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, -* @wackerow @corwintines @pettinarip @minimalsm +* @wackerow @pettinarip @minimalsm # Owners of specific files /src/data/consensus-bounty-hunters.json @asanso @fredriksvantes -/src/data/wallets/new-to-crypto.ts @konopkja @minimalsm \ No newline at end of file +/src/data/wallets/new-to-crypto.ts @konopkja @minimalsm diff --git a/.storybook/modes.ts b/.storybook/modes.ts index e80df9d115d..22c5914b36a 100644 --- a/.storybook/modes.ts +++ b/.storybook/modes.ts @@ -14,7 +14,7 @@ export const viewportModes = breakpointSet.reduce<{ } }, {}) -const localesToTest = ["en", "fa"] +const localesToTest = ["en", "ar"] const locales = pickBy(baseLocales, (_, key) => localesToTest.includes(key)) export const langModes = Object.keys(locales).reduce<{ [locale: string]: { locale: string } diff --git a/.storybook/next-intl.ts b/.storybook/next-intl.ts index 7e793fde433..7c78b768786 100644 --- a/.storybook/next-intl.ts +++ b/.storybook/next-intl.ts @@ -3,7 +3,7 @@ export const baseLocales = { zh: { title: "中国人", left: "Zh" }, ru: { title: "Русский", left: "Ru" }, uk: { title: "українська", left: "Uk" }, - fa: { title: "فارسی", left: "Fa" }, + ar: { title: "العربية", left: "Ar" }, } // Only i18n files named in this array are being exposed to Storybook. Add filenames as necessary. diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b6e1316a797..7fa00381dfd 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -12,12 +12,14 @@ import type { } 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 HomepageSectionImage from "@/components/Homepage/HomepageSectionImage" +import PersonaModalCTA from "@/components/Homepage/PersonaModalCTA" import { getBentoBoxItems } from "@/components/Homepage/utils" import ValuesMarqueeFallback from "@/components/Homepage/ValuesMarquee/Fallback" import BlockHeap from "@/components/icons/block-heap.svg" @@ -450,44 +452,60 @@ const Page = async ({ params }: { params: PageParams }) => {
-
- {subHeroCTAs.map( - ({ label, description, href, className, Svg }, idx) => { - const Link = ( - props: Omit< - SvgButtonLinkProps, - "Svg" | "href" | "label" | "children" - > - ) => ( - -

{description}

-
- ) - return ( - - - - - ) - } - )} -
+ + {subHeroCTAs.map( + ({ label, description, href, className, Svg }, idx) => { + const Link = ( + props: Omit< + SvgButtonLinkProps, + "Svg" | "href" | "label" | "children" + > + ) => ( + +

{description}

+
+ ) + return ( + + + + + ) + } + )} +
, + // Variation1: "Start here" button with persona modal +
+ +
, + ]} + /> {/* What is Ethereum */}
{ const [isOpen, setIsOpen] = useState(false) + const [mounted, setMounted] = useState(false) const [selectedVariant, setSelectedVariant] = useLocalStorage( `ab-test-${testKey}`, null @@ -27,10 +29,14 @@ export const ABTestDebugPanel = ({ useOnClickOutside(panelRef, () => setIsOpen(false)) + useEffect(() => { + setMounted(true) + }, []) + const forceVariant = (variantIndex: number) => setSelectedVariant(variantIndex) - return ( + const panelContent = (
) + + // Only render portal on client side after mount + if (!mounted) return null + + return createPortal(panelContent, document.body) } diff --git a/src/components/Hero/HomeHero/index.tsx b/src/components/Hero/HomeHero/index.tsx index 64ed56b4f85..683ced7545b 100644 --- a/src/components/Hero/HomeHero/index.tsx +++ b/src/components/Hero/HomeHero/index.tsx @@ -3,6 +3,7 @@ import { getLocale, getTranslations } from "next-intl/server" import type { ClassNameProp } from "@/lib/types" +import ABTestWrapper from "@/components/AB/TestWrapper" import LanguageMorpher from "@/components/Homepage/LanguageMorpher" import { cn } from "@/lib/utils/cn" @@ -68,13 +69,44 @@ const HomeHero = async ({
- -
-

{t("page-index-title")}

-

- {t("page-index-description")} -

-
+ + +
+

{t("page-index-title")}

+

+ {t("page-index-description")} +

+
+
, + // Variation1: New title/subtitle for persona modal +
+ +
+

+ The internet +
+ that belongs to you +

+

+ Create, own, build, connect, and transact. +
+ Ethereum is a network that everyone can use and anyone can + build on. +

+
+
, + ]} + /> ) diff --git a/src/components/Homepage/PersonaModalCTA.tsx b/src/components/Homepage/PersonaModalCTA.tsx new file mode 100644 index 00000000000..aedc5b6a994 --- /dev/null +++ b/src/components/Homepage/PersonaModalCTA.tsx @@ -0,0 +1,173 @@ +"use client" + +import { 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, + 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" + +type PersonaLink = { + label: string + href: string + isExternal?: boolean +} + +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/" }, + { label: "Get a wallet", href: "/wallets/find-wallet/" }, + ], + }, + { + id: "developers", + label: "For developers", + Icon: Code, + iconBgClass: "bg-primary-low-contrast", + iconColorClass: "text-primary", + links: [ + { label: "Developer Hub", href: "/developers/" }, + { label: "Docs", href: "/developers/docs/" }, + ], + }, + { + id: "enterprise", + label: "For enterprise", + Icon: Building2, + iconBgClass: "bg-accent-c/20", + iconColorClass: "text-accent-c", + links: [ + { label: "Founders", href: "/founders/" }, + { + label: "Institutions", + href: "https://institutions.ethereum.org/", + isExternal: true, + }, + ], + }, +] + +type PersonaModalCTAProps = { + eventCategory: string +} + +const PersonaModalCTA = ({ eventCategory }: PersonaModalCTAProps) => { + const [isOpen, setIsOpen] = useState(false) + + const handleOpenChange = (open: boolean) => { + if (open) { + trackCustomEvent({ + eventCategory, + eventAction: "start here", + eventName: "start here", + }) + } + setIsOpen(open) + } + + const handleLinkClick = (label: string) => { + trackCustomEvent({ + eventCategory, + eventAction: "modal", + eventName: label, + }) + setIsOpen(false) + } + + return ( + + + + + + + + What brings you here? + + +
+ {categories.map( + ({ id, label, Icon, iconBgClass, iconColorClass, links }) => ( +
+ {/* Icon and Category Label */} +
+
+ +
+

+ {label} +

+
+ + {/* 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 && ( + + )} + + + +
+ ))} +
+
+ ) + )} +
+ +
+ ) +} + +export default PersonaModalCTA diff --git a/src/data-layer/tasks.ts b/src/data-layer/tasks.ts index 19863382013..5d825d6a475 100644 --- a/src/data-layer/tasks.ts +++ b/src/data-layer/tasks.ts @@ -5,7 +5,7 @@ * Hourly tasks run every hour. */ -import { schedules } from "@trigger.dev/sdk/v3" +import { retry, schedules } from "@trigger.dev/sdk/v3" import { fetchApps } from "./fetchers/fetchApps" import { fetchBeaconChain } from "./fetchers/fetchBeaconChain" @@ -86,8 +86,14 @@ const HOURLY: Task[] = [ async function runTasks(tasks: Task[]) { const results = await Promise.allSettled( - tasks.map(async ([key, fetch]) => { - const data = await fetch() + tasks.map(async ([key, fetchFn]) => { + const data = await retry.onThrow(fetchFn, { + maxAttempts: 3, + minTimeoutInMs: 2000, + maxTimeoutInMs: 30000, + factor: 2, + randomize: true, + }) await set(key, data) console.log(`✓ ${key}`) return key diff --git a/src/data/chains.ts b/src/data/chains.ts index 3e727ffae94..9cf11231161 100644 --- a/src/data/chains.ts +++ b/src/data/chains.ts @@ -1000,6 +1000,17 @@ const chains = [ }, chain: "Muster", }, + { + name: "RISE", + infoURL: "https://risechain.com", + chainId: 4153, + nativeCurrency: { + name: "Ether", + symbol: "ETH", + decimals: 18, + }, + chain: "ETH", + }, { name: "MegaETH Mainnet", infoURL: "https://megaeth.com", diff --git a/src/data/published.json b/src/data/published.json index cf508724554..dc3d78c28e4 100644 --- a/src/data/published.json +++ b/src/data/published.json @@ -1 +1 @@ -{"date":"2026-01-23"} +{"date":"2026-01-29"} diff --git a/src/lib/types.ts b/src/lib/types.ts index d1acb399a8e..a92dd4aeb21 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1151,6 +1151,7 @@ export interface ITutorial { published?: string | null lang: string isExternal: boolean + isTranslated?: boolean } export enum AppCategoryEnum { diff --git a/src/lib/utils/md.ts b/src/lib/utils/md.ts index 331604085b5..a65bd9ca48b 100644 --- a/src/lib/utils/md.ts +++ b/src/lib/utils/md.ts @@ -10,7 +10,7 @@ import { dateToString } from "@/lib/utils/date" import internalTutorialSlugs from "@/data/internalTutorials.json" -import { CONTENT_DIR, SITE_URL } from "@/lib/constants" +import { CONTENT_DIR, DEFAULT_LOCALE } from "@/lib/constants" import { toPosixPath } from "./relativePath" @@ -75,28 +75,39 @@ export const getPostSlugs = async (dir: string, filterRegex?: RegExp) => { export const getTutorialsData = async ( locale: string ): Promise => { - const tutorialData: ITutorial[] = [] - - // Fetch tutorials from public URLs in parallel + // Read tutorials from filesystem in parallel using dynamic imports const tutorialPromises = (internalTutorialSlugs as string[]).map( async (slug) => { try { - const path = - locale !== "en" - ? `/content/translations/${locale}/developers/tutorials/${slug}/index.md` - : `/content/developers/tutorials/${slug}/index.md` - - const url = new URL(path, SITE_URL).toString() - - const response = await fetch(url) - if (!response.ok) { - console.warn( - `Failed to fetch tutorial ${slug} for locale ${locale}: ${response.status}` - ) - return null + let fileContents: string + let isTranslated = true + + if (locale === DEFAULT_LOCALE) { + // English: read directly from content directory + fileContents = ( + await import( + `../../../public/content/developers/tutorials/${slug}/index.md` + ) + ).default + } else { + // Non-English: try translation first, fallback to English + try { + fileContents = ( + await import( + `../../../public/content/translations/${locale}/developers/tutorials/${slug}/index.md` + ) + ).default + } catch { + // Fallback to English content + fileContents = ( + await import( + `../../../public/content/developers/tutorials/${slug}/index.md` + ) + ).default + isTranslated = false + } } - const fileContents = await response.text() const { data, content } = matter(fileContents) const frontmatter = data as Frontmatter @@ -111,12 +122,11 @@ export const getTutorialsData = async ( published: dateToString(frontmatter.published), lang: frontmatter.lang, isExternal: false, + isTranslated, } } catch (error) { - console.warn( - `Error fetching tutorial ${slug} for locale ${locale}:`, - error - ) + // Only warn if English content is missing (actual error) + console.warn(`Error reading tutorial ${slug}:`, error) return null } } @@ -124,14 +134,8 @@ export const getTutorialsData = async ( const results = await Promise.all(tutorialPromises) - // Filter out null results (failed fetches) - results.forEach((tutorial) => { - if (tutorial) { - tutorialData.push(tutorial) - } - }) - - return tutorialData + // Filter out null results (missing tutorials) + return results.filter((tutorial) => tutorial !== null) as ITutorial[] } export const checkPathValidity = (