diff --git a/AGENTS.md b/AGENTS.md index 9f8bc8478b8..fede2fcfc3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,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 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]/collectibles/page.tsx b/app/[locale]/collectibles/page.tsx index ba582b15753..626cf8aa87d 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" @@ -111,7 +112,9 @@ export default async function Page(props: { params: Promise$ - {( - 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/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 }) => {
- {new Date(story.date).toLocaleDateString("en-US", { + {dateTimeFormat("en-US", { year: "numeric", month: "long", day: "numeric", - })} + }).format(new Date(story.date))}
-
@@ -99,7 +97,7 @@ export const useNetworkColumns: ColumnDef
- {new Intl.NumberFormat(meta.locale as Lang, {
+ {numberFormat(meta.locale as Lang, {
style: "currency",
currency: "USD",
notation: "compact",
@@ -172,10 +170,10 @@ export const useNetworkColumns: ColumnDef
$
- {row.original.txCosts.toLocaleString(meta.locale as Lang, {
+ {numberFormat(meta.locale as Lang, {
minimumFractionDigits: 2,
maximumFractionDigits: 3,
- })}
+ }).format(row.original.txCosts)}
-
- {new Intl.NumberFormat(meta.locale as Lang, {
+ {numberFormat(meta.locale as Lang, {
style: "currency",
currency: "USD",
notation: "compact",
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 (
)
}
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 (
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 dc5d4640e10..24dafb07d1b 100644
--- a/src/components/Simulator/screens/SendReceive/ReceivedEther.tsx
+++ b/src/components/Simulator/screens/SendReceive/ReceivedEther.tsx
@@ -4,6 +4,8 @@ import { AnimatePresence, motion } from "motion/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 e513f119110..495d2384a9c 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/types.ts b/src/lib/types.ts
index d103d883653..0a92481106e 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/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