From 290859a65de3f9b899d07c125afabde0f210a500 Mon Sep 17 00:00:00 2001
From: wackerow <54227730+wackerow@users.noreply.github.com>
Date: Tue, 17 Mar 2026 20:46:01 -0700
Subject: [PATCH 1/5] fix(i18n): number formatting
- feat: add numberFormat wrapper util handling `ar` and `ur` numbering system overrides -- recommended by Gemini as best-practices for web3 content
- refactor: convert usage of Intl.NumberFormat to use imported numberFormat helper util throughout repo
- deprecate: getLocaleForNumberFormat helper which was performing no logic in its function call
- refactor: migrate numberToPercent to numbers.ts and remove Lang type preference in favor of string for locale
- fix: apply `locale` property where appropriate over hard-coded "en" or `undefined`
- docs: add AGENTS documentation for use of numberFormat as preferred approach over Intl.NumberFormat
---
AGENTS.md | 1 +
.../tools/_components/ToolModalContents.tsx | 5 +-
.../resources/_components/SlotCountdown.tsx | 4 +-
app/[locale]/resources/page.tsx | 3 +-
app/[locale]/resources/utils.tsx | 8 +--
app/[locale]/stablecoins/page.tsx | 3 +-
app/[locale]/utils.ts | 18 +++----
src/components/EthPriceCard.tsx | 5 +-
src/components/GitStars.tsx | 6 ++-
.../History/NetworkUpgradeSummary.tsx | 9 ++--
src/components/Homepage/KPISection.tsx | 3 +-
.../hooks/useNetworkColumns.tsx | 5 +-
.../Quiz/QuizWidget/QuizSummary.tsx | 2 +-
src/components/Quiz/QuizzesStats.tsx | 2 +-
src/components/Quiz/utils.ts | 13 ++---
.../Simulator/WalletHome/TokenBalanceItem.tsx | 6 ++-
.../Simulator/WalletHome/WalletBalance.tsx | 4 +-
.../screens/SendReceive/ReceivedEther.tsx | 6 ++-
.../screens/SendReceive/SendEther.tsx | 7 +--
.../screens/SendReceive/SendSummary.tsx | 9 ++--
.../Simulator/screens/SendReceive/Success.tsx | 5 +-
src/components/Staking/StakingStatsBox.tsx | 12 ++---
src/lib/nav/localeToDisplayInfo.ts | 6 ++-
src/lib/utils/numberToPercent.ts | 14 ------
src/lib/utils/numbers.ts | 49 +++++++++++++++++--
src/lib/utils/translations.ts | 5 --
26 files changed, 119 insertions(+), 91 deletions(-)
delete mode 100644 src/lib/utils/numberToPercent.ts
diff --git a/AGENTS.md b/AGENTS.md
index f7169949639..1b240c57f83 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -153,6 +153,7 @@ pnpm events-import # Import community events
6. **Consider i18n** - All user-facing text should be translatable (use `getTranslations` and `getLocale`)
7. **Mobile-first** - Design for mobile, enhance for desktop
8. **Accessibility** - Use Radix primitives, semantic HTML
+9. **Use `numberFormat()`** - Always use the wrapper in `src/lib/utils/numbers.ts` instead of `new Intl.NumberFormat()`. Same API, but enforces correct numbering systems for Urdu and Arabic locales.
### Component Development
diff --git a/app/[locale]/developers/tools/_components/ToolModalContents.tsx b/app/[locale]/developers/tools/_components/ToolModalContents.tsx
index 4d0ba038dfe..46d6ae60a4d 100644
--- a/app/[locale]/developers/tools/_components/ToolModalContents.tsx
+++ b/app/[locale]/developers/tools/_components/ToolModalContents.tsx
@@ -10,6 +10,7 @@ import { ButtonLink } from "@/components/ui/buttons/Button"
import { Tag, TagsInlineText } from "@/components/ui/tag"
import { formatDate, getValidDate } from "@/lib/utils/date"
+import { numberFormat } from "@/lib/utils/numbers"
import { isExternal } from "@/lib/utils/url"
import { DEV_TOOL_CATEGORY_SLUGS } from "../constants"
@@ -125,7 +126,7 @@ const ToolModalContents = async ({ tool }: { tool: DeveloperTool }) => {
className="text-sm"
title={t("page-developers-tools-stats-stargazers")}
>
- ({new Intl.NumberFormat(locale).format(stargazers)}
+ ({numberFormat(locale).format(stargazers)}
)
@@ -139,7 +140,7 @@ const ToolModalContents = async ({ tool }: { tool: DeveloperTool }) => {
title={t("page-developers-tools-stats-downloads")}
>
(
- {new Intl.NumberFormat(locale, {
+ {numberFormat(locale, {
notation: "compact",
}).format(downloads)}
diff --git a/app/[locale]/resources/_components/SlotCountdown.tsx b/app/[locale]/resources/_components/SlotCountdown.tsx
index 2a5bf5fa062..3522964762f 100644
--- a/app/[locale]/resources/_components/SlotCountdown.tsx
+++ b/app/[locale]/resources/_components/SlotCountdown.tsx
@@ -5,6 +5,8 @@ import { useLocale } from "next-intl"
import RadialChart from "@/components/RadialChart"
+import { numberFormat } from "@/lib/utils/numbers"
+
const SlotCountdownChart = ({ children }: { children: string }) => {
const [timeToNextBlock, setTimeToNextBlock] = useState(12)
const locale = useLocale()
@@ -28,7 +30,7 @@ const SlotCountdownChart = ({ children }: { children: string }) => {
{
// Extract blob stats directly (getBlobscanStats returns BlobscanStats, not wrapped in MetricReturnData)
const blobStats = {
avgBlobFee: blobscanOverallStats.avgBlobFee,
- totalBlobs: new Intl.NumberFormat(undefined, {
+ totalBlobs: numberFormat(locale, {
notation: "compact",
maximumFractionDigits: 1,
}).format(blobscanOverallStats.totalBlobs),
diff --git a/app/[locale]/resources/utils.tsx b/app/[locale]/resources/utils.tsx
index 5d83d0236d3..e9158e46c77 100644
--- a/app/[locale]/resources/utils.tsx
+++ b/app/[locale]/resources/utils.tsx
@@ -1,8 +1,6 @@
import dynamic from "next/dynamic"
import { getLocale, getTranslations } from "next-intl/server"
-import { Lang } from "@/lib/types"
-
import BigNumber from "@/components/BigNumber"
import SectionIconArrowsFullscreen from "@/components/icons/arrows-fullscreen.svg"
import SectionIconEthGlyph from "@/components/icons/eth-glyph.svg"
@@ -12,7 +10,6 @@ import SectionIconPrivacy from "@/components/icons/privacy.svg"
import { Spinner } from "@/components/ui/spinner"
import { formatSmallUSD } from "@/lib/utils/numbers"
-import { getLocaleForNumberFormat } from "@/lib/utils/translations"
import type { DashboardBox, DashboardSection } from "./types"
@@ -86,7 +83,6 @@ export const getResources = async ({
}): Promise => {
const locale = await getLocale()
const t = await getTranslations({ locale, namespace: "page-resources" })
- const localeForNumberFormat = getLocaleForNumberFormat(locale as Lang)
// Fetch ETH price using the new data-layer function (already cached)
const ethPrice = await getEthPrice()
@@ -104,7 +100,7 @@ export const getResources = async ({
value: formatSmallUSD(
// Converting value from wei to USD
avgBlobFee * 1e-18 * ethPrice.value,
- localeForNumberFormat
+ locale
),
}
@@ -113,7 +109,7 @@ export const getResources = async ({
? { error: txCostsMedianUsd.error }
: {
...txCostsMedianUsd,
- value: formatSmallUSD(txCostsMedianUsd.value, localeForNumberFormat),
+ value: formatSmallUSD(txCostsMedianUsd.value, locale),
}
const networkBoxes: DashboardBox[] = [
diff --git a/app/[locale]/stablecoins/page.tsx b/app/[locale]/stablecoins/page.tsx
index 1bb1dc9c00c..aeaf9f21815 100644
--- a/app/[locale]/stablecoins/page.tsx
+++ b/app/[locale]/stablecoins/page.tsx
@@ -33,6 +33,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { cn } from "@/lib/utils/cn"
import { getAppPageContributorInfo } from "@/lib/utils/contributors"
import { getMetadata } from "@/lib/utils/metadata"
+import { numberFormat } from "@/lib/utils/numbers"
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
import { stablecoins } from "./data"
@@ -117,7 +118,7 @@ async function Page({ params }: { params: PageParams }) {
.sort((a, b) => b.market_cap - a.market_cap)
.map(({ market_cap, ...rest }) => ({
...rest,
- marketCap: new Intl.NumberFormat("en-US", {
+ marketCap: numberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
diff --git a/app/[locale]/utils.ts b/app/[locale]/utils.ts
index cc4484c3aa8..baf20b1b1f2 100644
--- a/app/[locale]/utils.ts
+++ b/app/[locale]/utils.ts
@@ -7,10 +7,10 @@ import { getTranslations } from "next-intl/server"
import type { AllHomepageActivityData, Lang, StatsBoxMetric } from "@/lib/types"
-import { getLocaleForNumberFormat } from "@/lib/utils/translations"
+import { numberFormat } from "@/lib/utils/numbers"
const formatLargeUSD = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
style: "currency",
currency: "USD",
notation: "compact",
@@ -20,7 +20,7 @@ const formatLargeUSD = (value: number, locale: string): string => {
}
const formatSmallUSD = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
style: "currency",
currency: "USD",
notation: "compact",
@@ -30,7 +30,7 @@ const formatSmallUSD = (value: number, locale: string): string => {
}
const formatLargeNumber = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
notation: "compact",
minimumSignificantDigits: 3,
maximumSignificantDigits: 4,
@@ -49,8 +49,6 @@ export const getActivity = async (
): Promise => {
const t = await getTranslations("page-index")
- const localeForNumberFormat = getLocaleForNumberFormat(locale)
-
const hasEthStakerAndPriceData =
"value" in totalEthStaked && "value" in ethPrice
const totalStakedInUsd = hasEthStakerAndPriceData
@@ -68,7 +66,7 @@ export const getActivity = async (
}
: {
...totalEthStaked,
- value: formatLargeUSD(totalStakedInUsd, localeForNumberFormat),
+ value: formatLargeUSD(totalStakedInUsd, locale),
}
const valueLocked =
@@ -76,7 +74,7 @@ export const getActivity = async (
? { error: totalValueLocked.error }
: {
...totalValueLocked,
- value: formatLargeUSD(totalValueLocked.value, localeForNumberFormat),
+ value: formatLargeUSD(totalValueLocked.value, locale),
}
const txs =
@@ -84,7 +82,7 @@ export const getActivity = async (
? { error: txCount.error }
: {
...txCount,
- value: formatLargeNumber(txCount.value, localeForNumberFormat),
+ value: formatLargeNumber(txCount.value, locale),
}
const medianTxCost =
@@ -92,7 +90,7 @@ export const getActivity = async (
? { error: txCostsMedianUsd.error }
: {
...txCostsMedianUsd,
- value: formatSmallUSD(txCostsMedianUsd.value, localeForNumberFormat),
+ value: formatSmallUSD(txCostsMedianUsd.value, locale),
}
const metrics: StatsBoxMetric[] = [
diff --git a/src/components/EthPriceCard.tsx b/src/components/EthPriceCard.tsx
index 1b715c6b218..eeffcbee742 100644
--- a/src/components/EthPriceCard.tsx
+++ b/src/components/EthPriceCard.tsx
@@ -10,6 +10,7 @@ import Tooltip from "@/components/Tooltip"
import InlineLink from "@/components/ui/Link"
import { cn } from "@/lib/utils/cn"
+import { numberFormat } from "@/lib/utils/numbers"
import { Flex } from "./ui/flex"
@@ -70,7 +71,7 @@ const EthPriceCard = ({
const hasData = "data" in state
const formatPrice = (price: string) =>
- new Intl.NumberFormat(locale, {
+ numberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
@@ -78,7 +79,7 @@ const EthPriceCard = ({
}).format(+price)
const formatPercentage = (amount: number): string =>
- new Intl.NumberFormat(locale, {
+ numberFormat(locale, {
style: "percent",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
diff --git a/src/components/GitStars.tsx b/src/components/GitStars.tsx
index 4eb5d4e8727..bb0cd3a93cc 100644
--- a/src/components/GitStars.tsx
+++ b/src/components/GitStars.tsx
@@ -4,6 +4,8 @@ import Github from "@/components/icons/github.svg"
import { Center, Flex } from "@/components/ui/flex"
import { BaseLink, LinkProps } from "@/components/ui/Link"
+import { numberFormat } from "@/lib/utils/numbers"
+
import Emoji from "./Emoji"
type GitHubRepo = {
@@ -18,8 +20,8 @@ type GitStarsProps = Omit & {
const GitStars = ({ gitHubRepo, hideStars, ...props }: GitStarsProps) => {
const locale = useLocale()
- // Use Intl.NumberFormat to format the number for locale
- const starsString = Intl.NumberFormat(locale, {
+ // Use numberFormat to format the number for locale
+ const starsString = numberFormat(locale, {
compactDisplay: "short",
}).format(gitHubRepo.stargazerCount)
diff --git a/src/components/History/NetworkUpgradeSummary.tsx b/src/components/History/NetworkUpgradeSummary.tsx
index 230219f1e85..5752e185ec5 100644
--- a/src/components/History/NetworkUpgradeSummary.tsx
+++ b/src/components/History/NetworkUpgradeSummary.tsx
@@ -3,11 +3,9 @@
import { useEffect, useState } from "react"
import { useLocale } from "next-intl"
-import type { Lang } from "@/lib/types"
-
import { Flex, Stack } from "@/components/ui/flex"
-import { getLocaleForNumberFormat } from "@/lib/utils/translations"
+import { numberFormat } from "@/lib/utils/numbers"
import networkUpgradeSummaryData from "@/data/networkUpgradeSummaryData"
@@ -23,7 +21,6 @@ type NetworkUpgradeSummaryProps = {
const NetworkUpgradeSummary = ({ name }: NetworkUpgradeSummaryProps) => {
const [formattedUTC, setFormattedUTC] = useState("")
const locale = useLocale()
- const localeForStatsBoxNumbers = getLocaleForNumberFormat(locale as Lang)
const { t } = useTranslation("page-history")
const {
@@ -57,7 +54,7 @@ const NetworkUpgradeSummary = ({ name }: NetworkUpgradeSummaryProps) => {
{t(translationKey)}:{" "}
- {new Intl.NumberFormat(localeForStatsBoxNumbers).format(number)}
+ {numberFormat(locale).format(number)}
)
@@ -93,7 +90,7 @@ const NetworkUpgradeSummary = ({ name }: NetworkUpgradeSummaryProps) => {
{t("page-history:page-history-eth-price")}:{" "}
- {new Intl.NumberFormat(localeForStatsBoxNumbers, {
+ {numberFormat(locale, {
style: "currency",
currency: "USD",
}).format(ethPriceInUSD)}
diff --git a/src/components/Homepage/KPISection.tsx b/src/components/Homepage/KPISection.tsx
index 3b9a1d93b7b..7612c815475 100644
--- a/src/components/Homepage/KPISection.tsx
+++ b/src/components/Homepage/KPISection.tsx
@@ -7,6 +7,7 @@ import { useIntersectionObserver } from "usehooks-ts"
import { Section, SectionHeader, SectionTag } from "@/components/ui/section"
import { cn } from "@/lib/utils/cn"
+import { numberFormat } from "@/lib/utils/numbers"
type KPISectionProps = {
accountHolders: number | null
@@ -133,7 +134,7 @@ function formatNumber(value: number): string {
return `${Math.round(value / 1_000_000)}M`
}
if (value >= 1_000) {
- return new Intl.NumberFormat("en-US").format(value)
+ return numberFormat("en-US").format(value)
}
return value.toString()
}
diff --git a/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx b/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx
index 5af5d6184bc..7b4d1782641 100644
--- a/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx
+++ b/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx
@@ -16,6 +16,7 @@ import { TableCell, TableHead } from "@/components/ui/table"
import { cn } from "@/lib/utils/cn"
import { trackCustomEvent } from "@/lib/utils/matomo"
+import { numberFormat } from "@/lib/utils/numbers"
export const useNetworkColumns: ColumnDef[] = [
{
@@ -99,7 +100,7 @@ export const useNetworkColumns: ColumnDef[] = [
- {new Intl.NumberFormat(meta.locale as Lang, {
+ {numberFormat(meta.locale as Lang, {
style: "currency",
currency: "USD",
notation: "compact",
@@ -224,7 +225,7 @@ export const useNetworkColumns: ColumnDef[] = [
)}
>
- {new Intl.NumberFormat(meta.locale as Lang, {
+ {numberFormat(meta.locale as Lang, {
style: "currency",
currency: "USD",
notation: "compact",
diff --git a/src/components/Quiz/QuizWidget/QuizSummary.tsx b/src/components/Quiz/QuizWidget/QuizSummary.tsx
index c223a879963..961199a3fbe 100644
--- a/src/components/Quiz/QuizWidget/QuizSummary.tsx
+++ b/src/components/Quiz/QuizWidget/QuizSummary.tsx
@@ -3,7 +3,7 @@ import { useLocale } from "next-intl"
import { HStack, VStack } from "@/components/ui/flex"
import { cn } from "@/lib/utils/cn"
-import { numberToPercent } from "@/lib/utils/numberToPercent"
+import { numberToPercent } from "@/lib/utils/numbers"
import { screens } from "@/lib/utils/screen"
import { useMediaQuery } from "@/hooks/useMediaQuery"
diff --git a/src/components/Quiz/QuizzesStats.tsx b/src/components/Quiz/QuizzesStats.tsx
index 4f3d6c24af9..d89e73b4811 100644
--- a/src/components/Quiz/QuizzesStats.tsx
+++ b/src/components/Quiz/QuizzesStats.tsx
@@ -61,7 +61,7 @@ const QuizzesStats = ({
formattedCollectiveQuestionsAnswered,
formattedCollectiveAverageScore,
formattedCollectiveRetryRate,
- } = getFormattedStats(locale!, averageScoresArray)
+ } = getFormattedStats(locale, averageScoresArray)
return (
diff --git a/src/components/Quiz/utils.ts b/src/components/Quiz/utils.ts
index 7065b2874dc..3d6f6779c5e 100644
--- a/src/components/Quiz/utils.ts
+++ b/src/components/Quiz/utils.ts
@@ -1,11 +1,10 @@
import type {
CompletedQuizzes,
- Lang,
QuizShareStats,
QuizzesSection,
} from "@/lib/types"
-import { getLocaleForNumberFormat } from "@/lib/utils/translations"
+import { numberFormat } from "@/lib/utils/numbers"
import allQuizzesData, {
ethereumBasicsQuizzes,
@@ -57,23 +56,21 @@ export const shareOnTwitter = ({ score, total }: QuizShareStats): void => {
const mean = (values: number[]) =>
values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
-export const getFormattedStats = (language, average) => {
- const localeForNumbers = getLocaleForNumberFormat(language as Lang)
-
+export const getFormattedStats = (locale: string, valueSet: number[]) => {
// Initialize number and percent formatters
- const numberFormatter = new Intl.NumberFormat(localeForNumbers, {
+ const numberFormatter = numberFormat(locale, {
style: "decimal",
minimumSignificantDigits: 1,
maximumSignificantDigits: 3,
})
- const percentFormatter = new Intl.NumberFormat(localeForNumbers, {
+ const percentFormatter = numberFormat(locale, {
style: "percent",
minimumSignificantDigits: 1,
maximumSignificantDigits: 3,
})
- const computedAverage = average.length > 0 ? mean(average) : 0
+ const computedAverage = valueSet.length > 0 ? mean(valueSet) : 0
// Convert collective stats to fraction for percentage format
const normalizedCollectiveAverageScore = TOTAL_QUIZ_AVERAGE_SCORE / 100
diff --git a/src/components/Simulator/WalletHome/TokenBalanceItem.tsx b/src/components/Simulator/WalletHome/TokenBalanceItem.tsx
index cbf4c813b64..2552d18fd5f 100644
--- a/src/components/Simulator/WalletHome/TokenBalanceItem.tsx
+++ b/src/components/Simulator/WalletHome/TokenBalanceItem.tsx
@@ -1,5 +1,7 @@
import { Flex } from "@/components/ui/flex"
+import { numberFormat } from "@/lib/utils/numbers"
+
import { getMaxFractionDigitsUsd } from "../utils"
import { TokenBalance } from "./interfaces"
@@ -10,13 +12,13 @@ type TokenBalanceItemProps = {
export const TokenBalanceItem = ({ item }: TokenBalanceItemProps) => {
const { name, ticker, amount, usdConversion, Icon } = item
const usdAmount = amount * usdConversion
- const usdValue = Intl.NumberFormat("en-US", {
+ const usdValue = numberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: getMaxFractionDigitsUsd(usdAmount),
}).format(usdAmount)
- const tokenAmount = Intl.NumberFormat("en", {
+ const tokenAmount = numberFormat("en", {
maximumFractionDigits: 5,
}).format(amount)
return (
diff --git a/src/components/Simulator/WalletHome/WalletBalance.tsx b/src/components/Simulator/WalletHome/WalletBalance.tsx
index b5031c88136..30f499066dd 100644
--- a/src/components/Simulator/WalletHome/WalletBalance.tsx
+++ b/src/components/Simulator/WalletHome/WalletBalance.tsx
@@ -2,6 +2,8 @@ import React from "react"
import { Flex } from "@/components/ui/flex"
+import { numberFormat } from "@/lib/utils/numbers"
+
import { getMaxFractionDigitsUsd } from "../utils"
import { AddressPill } from "./AddressPill"
@@ -14,7 +16,7 @@ export const WalletBalance = ({ usdAmount = 0 }: WalletBalanceProps) => (
Your total
- {Intl.NumberFormat("en-US", {
+ {numberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
diff --git a/src/components/Simulator/screens/SendReceive/ReceivedEther.tsx b/src/components/Simulator/screens/SendReceive/ReceivedEther.tsx
index 5d973e5bed9..bd6f2fae86e 100644
--- a/src/components/Simulator/screens/SendReceive/ReceivedEther.tsx
+++ b/src/components/Simulator/screens/SendReceive/ReceivedEther.tsx
@@ -4,6 +4,8 @@ import { Info, X } from "lucide-react"
import type { SimulatorNavProps } from "@/lib/types"
+import { numberFormat } from "@/lib/utils/numbers"
+
import { getMaxFractionDigitsUsd } from "../../utils"
import { WalletHome } from "../../WalletHome"
import type { TokenBalance } from "../../WalletHome/interfaces"
@@ -60,11 +62,11 @@ export const ReceivedEther = ({
const tokenBalances = received ? tokensWithEthBalance : defaultTokenBalances
- const displayEth: string = new Intl.NumberFormat("en", {
+ const displayEth: string = numberFormat("en", {
maximumFractionDigits: 5,
}).format(ethReceiveAmount)
const usdReceiveAmount = ethReceiveAmount * ethPrice
- const displayUsd: string = new Intl.NumberFormat("en", {
+ const displayUsd: string = numberFormat("en", {
style: "currency",
currency: "USD",
notation: "compact",
diff --git a/src/components/Simulator/screens/SendReceive/SendEther.tsx b/src/components/Simulator/screens/SendReceive/SendEther.tsx
index 0f53c24516b..cd87450f7df 100644
--- a/src/components/Simulator/screens/SendReceive/SendEther.tsx
+++ b/src/components/Simulator/screens/SendReceive/SendEther.tsx
@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/buttons/Button"
import { Flex, HStack } from "@/components/ui/flex"
import { cn } from "@/lib/utils/cn"
+import { numberFormat } from "@/lib/utils/numbers"
import { EthTokenIcon } from "../../icons"
import { NotificationPopover } from "../../NotificationPopover"
@@ -21,7 +22,7 @@ export const SendEther = ({
setChosenAmount,
}: SendEtherProps) => {
const formatDollars = (amount: number): string =>
- new Intl.NumberFormat("en-US", {
+ numberFormat("en-US", {
style: "currency",
currency: "USD",
notation: "compact",
@@ -29,7 +30,7 @@ export const SendEther = ({
const usdAmount = formatDollars(ethPrice * ethBalance)
- const ethAmount = new Intl.NumberFormat("en", {
+ const ethAmount = numberFormat("en", {
maximumFractionDigits: 5,
}).format(ethBalance)
@@ -48,7 +49,7 @@ export const SendEther = ({
if (amount === maxUsdAmount) return "Max"
return formatDollars(amount)
}
- const formatChosenAmount = new Intl.NumberFormat("en", {
+ const formatChosenAmount = numberFormat("en", {
style: "currency",
currency: "USD",
notation: "compact",
diff --git a/src/components/Simulator/screens/SendReceive/SendSummary.tsx b/src/components/Simulator/screens/SendReceive/SendSummary.tsx
index 1874aea4a5c..8a541cfb828 100644
--- a/src/components/Simulator/screens/SendReceive/SendSummary.tsx
+++ b/src/components/Simulator/screens/SendReceive/SendSummary.tsx
@@ -3,6 +3,7 @@ import React from "react"
import { Flex } from "@/components/ui/flex"
import { cn } from "@/lib/utils/cn"
+import { numberFormat } from "@/lib/utils/numbers"
import { ETH_TRANSFER_FEE } from "../../constants"
import { getMaxFractionDigitsUsd } from "../../utils"
@@ -19,9 +20,9 @@ export const SendSummary = ({
recipient,
}: SendSummaryProps) => {
const formatEth = (amount: number): string =>
- new Intl.NumberFormat("en", { maximumFractionDigits: 5 }).format(amount)
+ numberFormat("en", { maximumFractionDigits: 5 }).format(amount)
- const formatChosenAmount = new Intl.NumberFormat("en", {
+ const formatChosenAmount = numberFormat("en", {
style: "currency",
currency: "USD",
notation: "compact",
@@ -63,7 +64,7 @@ export const SendSummary = ({
Network fees
- {Intl.NumberFormat("en", {
+ {numberFormat("en", {
maximumFractionDigits: getMaxFractionDigitsUsd(usdFee),
style: "currency",
currency: "USD",
@@ -71,7 +72,7 @@ export const SendSummary = ({
}).format(usdFee)}
(
- {Intl.NumberFormat("en", {
+ {numberFormat("en", {
maximumFractionDigits: 6,
}).format(ETH_TRANSFER_FEE)}{" "}
ETH)
diff --git a/src/components/Simulator/screens/SendReceive/Success.tsx b/src/components/Simulator/screens/SendReceive/Success.tsx
index 9ae40eb11a3..ddab73e95b1 100644
--- a/src/components/Simulator/screens/SendReceive/Success.tsx
+++ b/src/components/Simulator/screens/SendReceive/Success.tsx
@@ -6,6 +6,7 @@ import { Flex, VStack } from "@/components/ui/flex"
import { Spinner } from "@/components/ui/spinner"
import { cn } from "@/lib/utils/cn"
+import { numberFormat } from "@/lib/utils/numbers"
import { getMaxFractionDigitsUsd } from "../../utils"
import { WalletHome } from "../../WalletHome"
@@ -31,14 +32,14 @@ export const Success = ({
const usdAmount = sentEthAmount * ethPrice
- const usdValue = new Intl.NumberFormat("en", {
+ const usdValue = numberFormat("en", {
style: "currency",
currency: "USD",
notation: "compact",
maximumFractionDigits: getMaxFractionDigitsUsd(usdAmount),
}).format(usdAmount)
- const sentEthValue = new Intl.NumberFormat("en", {
+ const sentEthValue = numberFormat("en", {
maximumFractionDigits: 5,
}).format(sentEthAmount)
diff --git a/src/components/Staking/StakingStatsBox.tsx b/src/components/Staking/StakingStatsBox.tsx
index 2aa803b4e9e..3bacac653f5 100644
--- a/src/components/Staking/StakingStatsBox.tsx
+++ b/src/components/Staking/StakingStatsBox.tsx
@@ -1,12 +1,12 @@
import { Info } from "lucide-react"
import { useLocale } from "next-intl"
-import type { ChildOnlyProp, Lang, StakingStatsData } from "@/lib/types"
+import type { ChildOnlyProp, StakingStatsData } from "@/lib/types"
import Tooltip from "@/components/Tooltip"
import { Flex, VStack } from "@/components/ui/flex"
-import { getLocaleForNumberFormat } from "@/lib/utils/translations"
+import { numberFormat } from "@/lib/utils/numbers"
import InlineLink from "../ui/Link"
@@ -43,16 +43,12 @@ const StakingStatsBox = ({ data }: StakingStatsBoxProps) => {
const locale = useLocale()
const { t } = useTranslation("page-staking")
- const localeForStatsBoxNumbers = getLocaleForNumberFormat(locale! as Lang)
-
// Helper functions
const formatInteger = (amount: number): string =>
- amount
- ? new Intl.NumberFormat(localeForStatsBoxNumbers).format(amount)
- : "—"
+ amount ? numberFormat(locale).format(amount) : "—"
const formatPercentage = (amount: number): string =>
- new Intl.NumberFormat(localeForStatsBoxNumbers, {
+ numberFormat(locale, {
style: "percent",
minimumSignificantDigits: 2,
maximumSignificantDigits: 2,
diff --git a/src/lib/nav/localeToDisplayInfo.ts b/src/lib/nav/localeToDisplayInfo.ts
index c5d8fbbb2cf..b899e51c05f 100644
--- a/src/lib/nav/localeToDisplayInfo.ts
+++ b/src/lib/nav/localeToDisplayInfo.ts
@@ -11,6 +11,8 @@ import progressDataJson from "@/data/translationProgress.json"
import { DEFAULT_LOCALE } from "@/lib/constants"
+import { numberFormat } from "../utils/numbers"
+
const progressData = progressDataJson satisfies ProjectProgressData[]
const getProgressInfo = (
@@ -18,12 +20,12 @@ const getProgressInfo = (
approvalProgress: number,
wordsApproved: number
) => {
- const percentage = new Intl.NumberFormat(locale, {
+ const percentage = numberFormat(locale, {
style: "percent",
}).format(approvalProgress / 100)
const progress =
approvalProgress === 0 ? "<" + percentage.replace("0", "1") : percentage
- const words = new Intl.NumberFormat(locale).format(wordsApproved)
+ const words = numberFormat(locale).format(wordsApproved)
return { progress, words }
}
diff --git a/src/lib/utils/numberToPercent.ts b/src/lib/utils/numberToPercent.ts
deleted file mode 100644
index 9c2774fbbf8..00000000000
--- a/src/lib/utils/numberToPercent.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import type { Lang } from "@/lib/types"
-
-import { getLocaleForNumberFormat } from "@/lib/utils/translations"
-
-import { DEFAULT_LOCALE } from "../constants"
-
-export const numberToPercent = (
- num: number,
- locale: string | Lang = DEFAULT_LOCALE
-): string =>
- new Intl.NumberFormat(getLocaleForNumberFormat(locale as Lang), {
- style: "percent",
- maximumFractionDigits: 0,
- }).format(num)
diff --git a/src/lib/utils/numbers.ts b/src/lib/utils/numbers.ts
index ac48d4f33c5..35fe5b200f4 100644
--- a/src/lib/utils/numbers.ts
+++ b/src/lib/utils/numbers.ts
@@ -1,5 +1,40 @@
+/**
+ * A wrapper for Intl.NumberFormat that enforces Web3 numeral standards.
+ * - Arabic ('ar') defaults to Western Arabic numerals (1, 2, 3).
+ * - Urdu ('ur') defaults to Extended Arabic numerals (۱, ۲, ۳).
+ * - All other locales default to 'latn' to prevent browser-specific quirks.
+ */
+export function numberFormat(
+ locale: string,
+ // locales?: string | string[],
+ options?: Intl.NumberFormatOptions
+): Intl.NumberFormat {
+ // If numberingSystem explicitly passed, respect it.
+ // Otherwise, apply our localization rules.
+ let numberingSystem = options?.numberingSystem
+
+ if (!numberingSystem) {
+ if (locale === "ur") {
+ // Force Extended Arabic numerals for Urdu
+ numberingSystem = "arabext"
+ } else {
+ // Force Western Arabic numerals ('latn') for Arabic and all other locales
+ // to override browser defaults that might try to use native scripts.
+ numberingSystem = "latn"
+ }
+ }
+
+ // Merge the resolved numbering system into the options
+ const finalOptions: Intl.NumberFormatOptions = {
+ ...options,
+ ...(numberingSystem && { numberingSystem }),
+ }
+
+ return new Intl.NumberFormat(locale, finalOptions)
+}
+
export const formatLargeUSD = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
style: "currency",
currency: "USD",
notation: "compact",
@@ -9,7 +44,7 @@ export const formatLargeUSD = (value: number, locale: string): string => {
}
export const formatSmallUSD = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
style: "currency",
currency: "USD",
notation: "compact",
@@ -19,7 +54,7 @@ export const formatSmallUSD = (value: number, locale: string): string => {
}
export const formatLargeNumber = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
notation: "compact",
minimumSignificantDigits: 3,
maximumSignificantDigits: 4,
@@ -27,10 +62,16 @@ export const formatLargeNumber = (value: number, locale: string): string => {
}
export const formatPriceUSD = (value: number, locale: string): string => {
- return new Intl.NumberFormat(locale, {
+ return numberFormat(locale, {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
}
+
+export const numberToPercent = (num: number, locale: string): string =>
+ numberFormat(locale, {
+ style: "percent",
+ maximumFractionDigits: 0,
+ }).format(num)
diff --git a/src/lib/utils/translations.ts b/src/lib/utils/translations.ts
index 13abc647a80..ebc864819e9 100644
--- a/src/lib/utils/translations.ts
+++ b/src/lib/utils/translations.ts
@@ -142,11 +142,6 @@ export const isLocaleValidISO639_1 = (locale: string) => {
return i18nConfig.find((language) => language.code === locale)?.validISO639_1
}
-// Returns the locale to use for number formatting
-// Note: Previously had special case for Farsi (fa) to use Hindu-Arabic numerals,
-// but Farsi is no longer in the active locale set
-export const getLocaleForNumberFormat = (locale: Lang): Lang => locale
-
export const isLang = (lang: string) => {
return i18nConfig.map((language) => language.code).includes(lang)
}
From 9e5154394d8279f313f7670fff27f302447f3c86 Mon Sep 17 00:00:00 2001
From: wackerow <54227730+wackerow@users.noreply.github.com>
Date: Tue, 17 Mar 2026 21:11:20 -0700
Subject: [PATCH 2/5] refactor: number.toLocaleString to numberFormat standard
---
app/[locale]/collectibles/page.tsx | 13 ++++++++++---
app/[locale]/layer-2/_components/layer-2.tsx | 14 ++++++++------
.../hooks/useNetworkColumns.tsx | 15 ++++++---------
3 files changed, 24 insertions(+), 18 deletions(-)
diff --git a/app/[locale]/collectibles/page.tsx b/app/[locale]/collectibles/page.tsx
index 2f0b4f2d6ad..a31c713a70c 100644
--- a/app/[locale]/collectibles/page.tsx
+++ b/app/[locale]/collectibles/page.tsx
@@ -15,6 +15,7 @@ import { Section } from "@/components/ui/section"
import { getAppPageContributorInfo } from "@/lib/utils/contributors"
import { getMetadata } from "@/lib/utils/metadata"
+import { numberFormat } from "@/lib/utils/numbers"
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
import CollectiblesPage from "./_components/Collectibles/lazy"
@@ -110,7 +111,9 @@ export default async function Page({ params }: { params: PageParams }) {
{/* Minted */}
- {stats.collectorsCount?.toLocaleString(locale) ?? "-"}
+ {stats.collectorsCount
+ ? numberFormat(locale).format(stats.collectorsCount)
+ : "-"}
{t("page-collectibles-stats-minted")}
@@ -119,7 +122,9 @@ export default async function Page({ params }: { params: PageParams }) {
{/* Collectors */}
- {stats.uniqueAddressesCount?.toLocaleString(locale) ?? "-"}
+ {stats.uniqueAddressesCount
+ ? numberFormat(locale).format(stats.uniqueAddressesCount)
+ : "-"}
{t("page-collectibles-stats-collectors")}
@@ -128,7 +133,9 @@ export default async function Page({ params }: { params: PageParams }) {
{/* Unique Badges */}
- {stats.collectiblesCount?.toLocaleString(locale) ?? "-"}
+ {stats.collectiblesCount
+ ? numberFormat(locale).format(stats.collectiblesCount)
+ : "-"}
{t("page-collectibles-stats-unique-badges")}
diff --git a/app/[locale]/layer-2/_components/layer-2.tsx b/app/[locale]/layer-2/_components/layer-2.tsx
index 73656d28d64..52587996cd9 100644
--- a/app/[locale]/layer-2/_components/layer-2.tsx
+++ b/app/[locale]/layer-2/_components/layer-2.tsx
@@ -12,6 +12,8 @@ import Translation from "@/components/Translation"
import { ButtonLink } from "@/components/ui/buttons/Button"
import InlineLink from "@/components/ui/Link"
+import { numberFormat } from "@/lib/utils/numbers"
+
import { Rollups } from "@/data/networks/networks"
import useTranslation from "@/hooks/useTranslation"
@@ -126,12 +128,10 @@ const Layer2Hub = ({
$
- {(
- growThePieData.dailyTxCosts["ethereum"] || 0
- ).toLocaleString(locale as Lang, {
+ {numberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
- })}
+ }).format(growThePieData.dailyTxCosts["ethereum"] || 0)}
{t("page-layer-2-blockchain-transaction-cost")}
@@ -143,10 +143,12 @@ const Layer2Hub = ({
$
- {medianTxCost.toLocaleString(locale as Lang, {
+ {numberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 3,
- })}
+ }).format(
+ typeof medianTxCost === "number" ? medianTxCost : 0
+ )}
{t("page-layer-2-networks-transaction-cost")}
diff --git a/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx b/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx
index 7b4d1782641..48d2e000e5f 100644
--- a/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx
+++ b/src/components/Layer2NetworksTable/hooks/useNetworkColumns.tsx
@@ -82,13 +82,10 @@ export const useNetworkColumns: ColumnDef[] = [
{row.original.txCosts ? (
<>
$
- {(row.original.txCosts || 0).toLocaleString(
- meta.locale as Lang,
- {
- minimumFractionDigits: 2,
- maximumFractionDigits: 3,
- }
- )}
+ {numberFormat(meta.locale as Lang, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 3,
+ }).format(row.original.txCosts || 0)}
>
) : (
-
@@ -173,10 +170,10 @@ export const useNetworkColumns: ColumnDef[] = [
{row.original.txCosts ? (
$
- {row.original.txCosts.toLocaleString(meta.locale as Lang, {
+ {numberFormat(meta.locale as Lang, {
minimumFractionDigits: 2,
maximumFractionDigits: 3,
- })}
+ }).format(row.original.txCosts)}
) : (
-
From b51450bc62f7ac3199a27477d42946150a7080ab Mon Sep 17 00:00:00 2001
From: wackerow <54227730+wackerow@users.noreply.github.com>
Date: Tue, 17 Mar 2026 21:53:11 -0700
Subject: [PATCH 3/5] fix(i18n): date formatting
- feat: add dateTimeFormat wrapper util handling ar and ur numbering system overrides -- recommended by Gemini as best-practices for web3 content
- refactor: convert usage of Intl.DateTimeFormat date.toLocaleDateString and date.toLocaleTimeString to use imported dateTimeformat helper util throughout repo
- refactor: migrate getLocaleFormattedDate to date.ts from time.ts
- docs: update AGENTS documentation for use of dateTimeFormat as preferred approach
---
AGENTS.md | 2 +-
.../10years/_components/torchHoldersData.ts | 8 ++--
.../CollectiblesProgress/index.tsx | 4 +-
.../_components/UpgradeCountdown.tsx | 5 +-
.../roadmap/_components/ReleaseCarousel.tsx | 4 +-
src/components/BigNumber/index.tsx | 4 +-
.../what-are-apps/WhatAreAppsStories.tsx | 5 +-
.../WalletSubComponent.tsx | 2 +-
.../History/NetworkUpgradeSummary.tsx | 5 +-
src/components/LocaleDateTime.tsx | 4 +-
src/components/RadialChart/index.tsx | 4 +-
src/lib/utils/date.ts | 46 +++++++++++++++++--
src/lib/utils/time.ts | 10 ++--
13 files changed, 72 insertions(+), 31 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 1b240c57f83..2970bfb46e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -153,7 +153,7 @@ pnpm events-import # Import community events
6. **Consider i18n** - All user-facing text should be translatable (use `getTranslations` and `getLocale`)
7. **Mobile-first** - Design for mobile, enhance for desktop
8. **Accessibility** - Use Radix primitives, semantic HTML
-9. **Use `numberFormat()`** - Always use the wrapper in `src/lib/utils/numbers.ts` instead of `new Intl.NumberFormat()`. Same API, but enforces correct numbering systems for Urdu and Arabic locales.
+9. **Use locale-aware formatting wrappers** - Use `numberFormat()` from `src/lib/utils/numbers.ts` instead of `new Intl.NumberFormat()`, and `dateTimeFormat()` from `src/lib/utils/date.ts` instead of `new Intl.DateTimeFormat()` / `.toLocaleDateString()` / `.toLocaleTimeString()`. Both enforce correct numbering systems and calendar for Urdu and Arabic locales.
### Component Development
diff --git a/app/[locale]/10years/_components/torchHoldersData.ts b/app/[locale]/10years/_components/torchHoldersData.ts
index 33202a057e1..57beb3a075a 100644
--- a/app/[locale]/10years/_components/torchHoldersData.ts
+++ b/app/[locale]/10years/_components/torchHoldersData.ts
@@ -1,3 +1,5 @@
+import { dateTimeFormat } from "@/lib/utils/date"
+
/**
* Pre-computed static torch holders data
* This data is final and will not be updated (the torch has been burned)
@@ -229,13 +231,13 @@ export const extractTwitterHandle = (twitterUrl: string): string | null => {
export const formatTorchDate = (timestamp: number): string => {
const date = new Date(timestamp * 1000)
- const month = date.toLocaleDateString("en-US", { month: "long" })
+ const month = dateTimeFormat("en-US", { month: "long" }).format(date)
const day = date.getDate().toString().padStart(2, "0")
- const time = date.toLocaleTimeString("en-US", {
+ const time = dateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
- })
+ }).format(date)
return `${month} ${day}, ${time}`
}
diff --git a/app/[locale]/collectibles/_components/CollectiblesProgress/index.tsx b/app/[locale]/collectibles/_components/CollectiblesProgress/index.tsx
index 5b46b0dff26..9ae2fcd1416 100644
--- a/app/[locale]/collectibles/_components/CollectiblesProgress/index.tsx
+++ b/app/[locale]/collectibles/_components/CollectiblesProgress/index.tsx
@@ -5,6 +5,8 @@ import { useLocale } from "next-intl"
import { Progress } from "@/components/ui/progress"
+import { dateTimeFormat } from "@/lib/utils/date"
+
import { type BadgeWithOwned } from "../CollectiblesContent"
import useTranslation from "@/hooks/useTranslation"
@@ -38,7 +40,7 @@ const CollectiblesProgress = ({ badges }: CollectiblesProgressProps) => {
{contributorSinceYear < Infinity && (
{t("page-collectibles-contributing-since")}:{" "}
- {new Intl.DateTimeFormat(locale, {
+ {dateTimeFormat(locale, {
year: "numeric",
}).format(new Date().setFullYear(contributorSinceYear))}
diff --git a/app/[locale]/resources/_components/UpgradeCountdown.tsx b/app/[locale]/resources/_components/UpgradeCountdown.tsx
index a9b478cccc5..e4312e12428 100644
--- a/app/[locale]/resources/_components/UpgradeCountdown.tsx
+++ b/app/[locale]/resources/_components/UpgradeCountdown.tsx
@@ -8,6 +8,8 @@ import type { NetworkUpgradeDetails } from "@/lib/types"
import { BaseLink } from "@/components/ui/Link"
+import { dateTimeFormat } from "@/lib/utils/date"
+
import networkUpgradeSummaryData from "@/data/networkUpgradeSummaryData"
const getLatestNetworkUpgradeDate = () => {
@@ -105,8 +107,7 @@ const UpgradeCountdown = () => {
scalingUpgradeCountdown
) : (
- Live Since{" "}
- {new Intl.DateTimeFormat(locale, {}).format(new Date(upgradeDate))}
+ Live Since {dateTimeFormat(locale).format(new Date(upgradeDate))}
)}
diff --git a/app/[locale]/roadmap/_components/ReleaseCarousel.tsx b/app/[locale]/roadmap/_components/ReleaseCarousel.tsx
index 121adb43218..81c3ff6409e 100644
--- a/app/[locale]/roadmap/_components/ReleaseCarousel.tsx
+++ b/app/[locale]/roadmap/_components/ReleaseCarousel.tsx
@@ -16,7 +16,7 @@ import {
} from "@/components/ui/carousel"
import { cn } from "@/lib/utils/cn"
-import { formatDate } from "@/lib/utils/date"
+import { dateTimeFormat, formatDate } from "@/lib/utils/date"
import { getReleasesData, Release } from "@/data/roadmap/releases"
@@ -100,7 +100,7 @@ const ReleaseCarousel = () => {
return ""
if ("plannedReleaseYear" in release && release.plannedReleaseYear)
- return new Intl.DateTimeFormat(locale, {
+ return dateTimeFormat(locale, {
year: "numeric",
}).format(new Date(Number(release.plannedReleaseYear), 0, 1))
diff --git a/src/components/BigNumber/index.tsx b/src/components/BigNumber/index.tsx
index 24d03bcd183..3c72b9d7c58 100644
--- a/src/components/BigNumber/index.tsx
+++ b/src/components/BigNumber/index.tsx
@@ -4,7 +4,7 @@ import { Info } from "lucide-react"
import { getLocale, getTranslations } from "next-intl/server"
import { cn } from "@/lib/utils/cn"
-import { isValidDate } from "@/lib/utils/date"
+import { dateTimeFormat, isValidDate } from "@/lib/utils/date"
import Tooltip from "../Tooltip"
import Link from "../ui/Link"
@@ -68,7 +68,7 @@ const BigNumber = async ({
const lastUpdatedDisplay =
lastUpdated && isValidDate(lastUpdated)
- ? new Intl.DateTimeFormat(locale, {
+ ? dateTimeFormat(locale, {
dateStyle: "medium",
}).format(new Date(lastUpdated))
: ""
diff --git a/src/components/Content/what-are-apps/WhatAreAppsStories.tsx b/src/components/Content/what-are-apps/WhatAreAppsStories.tsx
index 76d50d9f224..94c8719c619 100644
--- a/src/components/Content/what-are-apps/WhatAreAppsStories.tsx
+++ b/src/components/Content/what-are-apps/WhatAreAppsStories.tsx
@@ -9,6 +9,7 @@ import { Image } from "@/components/Image"
import { Button, ButtonLink } from "@/components/ui/buttons/Button"
import { cn } from "@/lib/utils/cn"
+import { dateTimeFormat } from "@/lib/utils/date"
const stories: Story[] = [
{
@@ -119,11 +120,11 @@ const WhatAreAppsStories = () => {
- {new Date(story.date).toLocaleDateString("en-US", {
+ {dateTimeFormat("en-US", {
year: "numeric",
month: "long",
day: "numeric",
- })}
+ }).format(new Date(story.date))}
))}
diff --git a/src/components/FindWalletProductTable/WalletSubComponent.tsx b/src/components/FindWalletProductTable/WalletSubComponent.tsx
index ceb5dfb1c1c..1606ddd1319 100644
--- a/src/components/FindWalletProductTable/WalletSubComponent.tsx
+++ b/src/components/FindWalletProductTable/WalletSubComponent.tsx
@@ -14,8 +14,8 @@ import Tooltip from "@/components/Tooltip"
import InlineLink from "@/components/ui/Link"
import { cn } from "@/lib/utils/cn"
+import { getLocaleFormattedDate } from "@/lib/utils/date"
import { trackCustomEvent } from "@/lib/utils/matomo"
-import { getLocaleFormattedDate } from "@/lib/utils/time"
import { useTranslation } from "@/hooks/useTranslation"
diff --git a/src/components/History/NetworkUpgradeSummary.tsx b/src/components/History/NetworkUpgradeSummary.tsx
index 5752e185ec5..0d94363f377 100644
--- a/src/components/History/NetworkUpgradeSummary.tsx
+++ b/src/components/History/NetworkUpgradeSummary.tsx
@@ -5,6 +5,7 @@ import { useLocale } from "next-intl"
import { Flex, Stack } from "@/components/ui/flex"
+import { dateTimeFormat } from "@/lib/utils/date"
import { numberFormat } from "@/lib/utils/numbers"
import networkUpgradeSummaryData from "@/data/networkUpgradeSummaryData"
@@ -36,7 +37,7 @@ const NetworkUpgradeSummary = ({ name }: NetworkUpgradeSummaryProps) => {
// calculate date format only on the client side to avoid hydration issues
useEffect(() => {
const date = new Date(dateTimeAsString as string)
- const formattedDate = date.toLocaleString(locale, {
+ const formattedDate = dateTimeFormat(locale, {
timeZone: "UTC",
month: "short",
day: "numeric",
@@ -44,7 +45,7 @@ const NetworkUpgradeSummary = ({ name }: NetworkUpgradeSummaryProps) => {
hour: "numeric",
minute: "numeric",
second: "numeric",
- })
+ }).format(date)
setFormattedUTC(`${formattedDate} +UTC`)
}, [dateTimeAsString, locale])
diff --git a/src/components/LocaleDateTime.tsx b/src/components/LocaleDateTime.tsx
index 2ab4c7e50b7..acd34bd89e5 100644
--- a/src/components/LocaleDateTime.tsx
+++ b/src/components/LocaleDateTime.tsx
@@ -1,5 +1,7 @@
import { useLocale } from "next-intl"
+import { dateTimeFormat } from "@/lib/utils/date"
+
type LocaleDateTimeProps = {
utcDateTime: string
hideDate?: boolean
@@ -45,7 +47,7 @@ const LocaleDateTime = ({
}
return (
- {new Intl.DateTimeFormat(locale, dateTimeOptions).format(date)}
+ {dateTimeFormat(locale, dateTimeOptions).format(date)}
)
}
diff --git a/src/components/RadialChart/index.tsx b/src/components/RadialChart/index.tsx
index 53a4d782050..08375a78243 100644
--- a/src/components/RadialChart/index.tsx
+++ b/src/components/RadialChart/index.tsx
@@ -6,7 +6,7 @@ import { useLocale } from "next-intl"
import { PolarAngleAxis, RadialBar, RadialBarChart } from "recharts"
import { cn } from "@/lib/utils/cn"
-import { isValidDate } from "@/lib/utils/date"
+import { dateTimeFormat, isValidDate } from "@/lib/utils/date"
import Tooltip from "../Tooltip"
import Link from "../ui/Link"
@@ -62,7 +62,7 @@ const RadialChart = ({
const lastUpdatedDisplay =
lastUpdated && isValidDate(lastUpdated)
- ? new Intl.DateTimeFormat(locale, {
+ ? dateTimeFormat(locale, {
dateStyle: "medium",
}).format(new Date(lastUpdated))
: ""
diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts
index 82d53ca18df..851ac1597fa 100644
--- a/src/lib/utils/date.ts
+++ b/src/lib/utils/date.ts
@@ -1,4 +1,35 @@
import { DEFAULT_LOCALE } from "../constants"
+import type { Lang } from "../types"
+
+/**
+ * A wrapper for Intl.DateTimeFormat that enforces Web3 date standards.
+ * - Forces the Gregorian calendar universally.
+ * - Arabic ('ar') and standard locales default to Western numerals (1, 2, 3).
+ * - Urdu ('ur') defaults to Extended Arabic numerals (۱, ۲, ۳).
+ */
+export function dateTimeFormat(
+ locale: string,
+ options?: Intl.DateTimeFormatOptions
+): Intl.DateTimeFormat {
+ let numberingSystem = options?.numberingSystem
+
+ if (!numberingSystem) {
+ if (locale === "ur") {
+ numberingSystem = "arabext" // Native Urdu numerals
+ } else {
+ numberingSystem = "latn" // Western numerals for Arabic, Indic, etc.
+ }
+ }
+
+ const finalOptions: Intl.DateTimeFormatOptions = {
+ // ALWAYS force Gregorian for tech/Web3 consistency
+ calendar: "gregory",
+ ...options,
+ ...(numberingSystem && { numberingSystem }),
+ }
+
+ return new Intl.DateTimeFormat(locale, finalOptions)
+}
export const dateToString = (published: Date | string) =>
new globalThis.Date(published).toISOString().split("T")[0]
@@ -27,12 +58,12 @@ export const formatDate = (
if (/^\d{4}$/.test(date)) {
return date
}
- return new Date(date).toLocaleDateString(locale, {
+ return dateTimeFormat(locale, {
month: "long",
day: "numeric",
year: "numeric",
...options,
- })
+ }).format(new Date(date))
}
export const isDateReached = (date: string) => {
@@ -47,20 +78,25 @@ export const formatDateRange = (
locale: string = DEFAULT_LOCALE,
options?: Intl.DateTimeFormatOptions
) =>
- new Intl.DateTimeFormat(locale, {
+ dateTimeFormat(locale, {
month: "short",
day: "numeric",
...options,
}).formatRange(new Date(start), new Date(end || start))
export const getLocaleYear = (
- locale: Intl.LocalesArgument = "en-US",
+ locale: string = "en-US",
date?: ConstructorParameters
[0]
) =>
- new Intl.DateTimeFormat(locale, { year: "numeric" }).format(
+ dateTimeFormat(locale, { year: "numeric" }).format(
date ? new Date(date) : new Date()
)
+export const getLocaleFormattedDate = (locale: Lang, date: string) => {
+ const walletLastUpdatedDate = new Date(date)
+ return dateTimeFormat(locale).format(walletLastUpdatedDate)
+}
+
/**
* Get ISO week number for a given date
* Used as seed for deterministic weekly rotation
diff --git a/src/lib/utils/time.ts b/src/lib/utils/time.ts
index 0eadcd13ad6..2394175c855 100644
--- a/src/lib/utils/time.ts
+++ b/src/lib/utils/time.ts
@@ -1,5 +1,7 @@
import { Lang } from "../types"
+import { dateTimeFormat } from "./date"
+
export const getLocaleTimestamp = (
locale: Lang,
timestamp: string,
@@ -13,11 +15,5 @@ export const getLocaleTimestamp = (
day: "numeric",
} as Intl.DateTimeFormatOptions)
const date = new Date(timestamp)
- return new Intl.DateTimeFormat(locale, opts).format(date)
-}
-
-export const getLocaleFormattedDate = (locale: Lang, date: string) => {
- const walletLastUpdatedDate = new Date(date)
-
- return new Intl.DateTimeFormat(locale).format(walletLastUpdatedDate)
+ return dateTimeFormat(locale, opts).format(date)
}
From 083472e207ceeb9df3f04037b3aaa17009aa55dd Mon Sep 17 00:00:00 2001
From: wackerow <54227730+wackerow@users.noreply.github.com>
Date: Tue, 17 Mar 2026 21:57:25 -0700
Subject: [PATCH 4/5] deprecate: unused i18n config properties
---
i18n.config.json | 100 +++++++++-------------------------
src/lib/types.ts | 1 -
src/lib/utils/translations.ts | 4 --
3 files changed, 25 insertions(+), 80 deletions(-)
diff --git a/i18n.config.json b/i18n.config.json
index 7c5b7605fd9..bc5e72fa8b1 100644
--- a/i18n.config.json
+++ b/i18n.config.json
@@ -4,224 +4,174 @@
"crowdinCode": "en",
"name": "English",
"localName": "English",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "ar",
"crowdinCode": "ar",
"name": "Arabic",
"localName": "العربية",
- "langDir": "rtl",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "rtl"
},
{
"code": "bn",
"crowdinCode": "bn",
"name": "Bengali",
"localName": "বাংলা",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "cs",
"crowdinCode": "cs",
"name": "Czech",
"localName": "Čeština",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "de",
"crowdinCode": "de",
"name": "German",
"localName": "Deutsch",
- "langDir": "ltr",
- "dateFormat": "DD/MM/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "es",
"crowdinCode": "es-EM",
"name": "Spanish",
"localName": "Español",
- "langDir": "ltr",
- "dateFormat": "DD/MM/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "fr",
"crowdinCode": "fr",
"name": "French",
"localName": "Français",
- "langDir": "ltr",
- "dateFormat": "DD/MM/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "hi",
"crowdinCode": "hi",
"name": "Hindi",
"localName": "हिन्दी",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "id",
"crowdinCode": "id",
"name": "Indonesian",
"localName": "Bahasa Indonesia",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "it",
"crowdinCode": "it",
"name": "Italian",
"localName": "Italiano",
- "langDir": "ltr",
- "dateFormat": "DD/MM/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "ja",
"crowdinCode": "ja",
"name": "Japanese",
"localName": "日本語",
- "langDir": "ltr",
- "dateFormat": "YYYY/MM/DD",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "ko",
"crowdinCode": "ko",
"name": "Korean",
"localName": "한국어",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "mr",
"crowdinCode": "mr",
"name": "Marathi",
"localName": "मराठी",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "pl",
"crowdinCode": "pl",
"name": "Polish",
"localName": "Polski",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "pt-br",
"crowdinCode": "pt-BR",
"name": "Portuguese (Brazilian)",
"localName": "Português",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "ru",
"crowdinCode": "ru",
"name": "Russian",
"localName": "Pусский",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "sw",
"crowdinCode": "sw",
"name": "Swahili",
"localName": "Kiswahili",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "ta",
"crowdinCode": "ta",
"name": "Tamil",
"localName": "தமிழ்",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "te",
"crowdinCode": "te",
"name": "Telugu",
"localName": "తెలుగు",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "tr",
"crowdinCode": "tr",
"name": "Turkish",
"localName": "Türkçe",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "uk",
"crowdinCode": "uk",
"name": "Ukrainian",
"localName": "Українська",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "ur",
"crowdinCode": "ur-IN",
"name": "Urdu",
"localName": "اردو",
- "langDir": "rtl",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "rtl"
},
{
"code": "vi",
"crowdinCode": "vi",
"name": "Vietnamese",
"localName": "Tiếng Việt",
- "langDir": "ltr",
- "dateFormat": "MM/DD/YYYY",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "zh-tw",
"crowdinCode": "zh-TW",
"name": "Chinese Traditional",
"localName": "繁體中文",
- "langDir": "ltr",
- "dateFormat": "YYYY-MM-DD",
- "validISO639_1": true
+ "langDir": "ltr"
},
{
"code": "zh",
"crowdinCode": "zh-CN",
"name": "Chinese Simplified",
"localName": "简体中文",
- "langDir": "ltr",
- "dateFormat": "YYYY-MM-DD",
- "validISO639_1": true
+ "langDir": "ltr"
}
]
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 654bfb5aff2..cd08aa6797e 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -166,7 +166,6 @@ export type I18nLocale = {
name: string
localName: string
langDir: Direction
- dateFormat: string
/**
* @property forceLocalName - Optional flag to indicate that the local name should be used instead of the fallback from `Intl.DisplayName`.
* Fallback used when locale language name matches English name.
diff --git a/src/lib/utils/translations.ts b/src/lib/utils/translations.ts
index ebc864819e9..dc673b9f80b 100644
--- a/src/lib/utils/translations.ts
+++ b/src/lib/utils/translations.ts
@@ -138,10 +138,6 @@ export const filterRealLocales = (locales: string[] | undefined) => {
return locales?.filter((locale) => locale !== FAKE_LOCALE) || []
}
-export const isLocaleValidISO639_1 = (locale: string) => {
- return i18nConfig.find((language) => language.code === locale)?.validISO639_1
-}
-
export const isLang = (lang: string) => {
return i18nConfig.map((language) => language.code).includes(lang)
}
From e126b67264caedf149b4d4eaa314b4552fde7e6d Mon Sep 17 00:00:00 2001
From: wackerow <54227730+wackerow@users.noreply.github.com>
Date: Fri, 27 Mar 2026 09:14:14 -0700
Subject: [PATCH 5/5] chore: remove commented out code line
Co-authored-by: Pablo Pettinari
---
src/lib/utils/numbers.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/lib/utils/numbers.ts b/src/lib/utils/numbers.ts
index 35fe5b200f4..48a108611a8 100644
--- a/src/lib/utils/numbers.ts
+++ b/src/lib/utils/numbers.ts
@@ -6,7 +6,6 @@
*/
export function numberFormat(
locale: string,
- // locales?: string | string[],
options?: Intl.NumberFormatOptions
): Intl.NumberFormat {
// If numberingSystem explicitly passed, respect it.