diff --git a/src/components/ui/jam/Balance.tsx b/src/components/ui/jam/Balance.tsx
index 86e88597..353d4c85 100644
--- a/src/components/ui/jam/Balance.tsx
+++ b/src/components/ui/jam/Balance.tsx
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type MouseEvent, type MouseEventHandler,
import { SnowflakeIcon } from 'lucide-react'
import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol'
import { useJamDisplayContext } from '@/context/JamDisplayContext'
-import { cn, satsToBtc, tryBtcToSat, isValidNumber, formatBtc, formatSats } from '@/lib/utils'
+import { cn, satsToBtc, tryBtcToSat, isValidNumber, getBtcParts, formatSats } from '@/lib/utils'
import type { AmountSats, Currency } from '@/types/global'
import styles from './Balance.module.css'
@@ -66,12 +66,10 @@ const BitcoinBalance = ({
highlightSignificantDigits = true,
...props
}: BitcoinBalanceProps) => {
- const numberString = formatBtc(satsToBtc(String(value)))
- const [rawIntegerPart, fractionalPart] = numberString.split(DECIMAL_POINT_CHAR)
+ const btcValue = satsToBtc(String(value))
+ const { sign, integerPart, fractionalPart, formatted } = getBtcParts(btcValue)
const fractionPartArray = [...fractionalPart]
- const sign = ['-', '+'].includes(rawIntegerPart[0]) ? rawIntegerPart[0] : undefined
- const integerPart = sign !== undefined ? rawIntegerPart.slice(1) : rawIntegerPart
const integerPartIsZero = integerPart === '0'
const fractionalPartStartsWithZero = fractionPartArray[0] === '0'
@@ -86,7 +84,7 @@ const BitcoinBalance = ({
data-integer-part-is-zero={integerPartIsZero}
data-fractional-part-starts-with-zero={fractionalPartStartsWithZero}
data-raw-value={value}
- data-formatted-value={numberString}
+ data-formatted-value={formatted}
>
{sign && {sign}}
{integerPart}
diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts
index 68b8e0a7..c567fb43 100644
--- a/src/lib/utils.test.ts
+++ b/src/lib/utils.test.ts
@@ -18,6 +18,7 @@ import {
parseSemanticVersion,
UNKNOWN_VERSION,
formatBtc,
+ getBtcParts,
formatSats,
BTC,
SATS,
@@ -36,7 +37,7 @@ import {
import type { WalletFileName } from './utils'
const withRuntimeLocale = (locale: string, callback: () => void) => {
- const toLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
+ const numberToLocaleStringMock = vi.spyOn(Number.prototype, 'toLocaleString').mockImplementation(function (
this: number,
locales,
options,
@@ -47,7 +48,7 @@ const withRuntimeLocale = (locale: string, callback: () => void) => {
try {
callback()
} finally {
- toLocaleStringMock.mockRestore()
+ numberToLocaleStringMock.mockRestore()
}
}
@@ -513,6 +514,42 @@ describe('formatBtc', () => {
})
})
+describe('getBtcParts', () => {
+ it('should decompose a BTC value into semantic parts', () => {
+ const parts = getBtcParts(1.5)
+ expect(parts.integerPart).toBe('1')
+ expect(parts.fractionalPart).toBe('50000000')
+ expect(parts.sign).toBeUndefined()
+ expect(parts.formatted).toBe('1.50000000')
+ })
+
+ it('should handle negative values', () => {
+ const parts = getBtcParts(-0.12345678)
+ expect(parts.sign).toBe('-')
+ expect(parts.integerPart).toBe('0')
+ expect(parts.fractionalPart).toBe('12345678')
+ })
+
+ it('should handle zero', () => {
+ const parts = getBtcParts(0)
+ expect(parts.integerPart).toBe('0')
+ expect(parts.fractionalPart).toBe('00000000')
+ expect(parts.sign).toBeUndefined()
+ })
+
+ it('should handle large values with grouping', () => {
+ const parts = getBtcParts(21000000)
+ expect(parts.integerPart).toBe('21000000')
+ expect(parts.fractionalPart).toBe('00000000')
+ })
+
+ it('should handle very small values', () => {
+ const parts = getBtcParts(0.00000001)
+ expect(parts.integerPart).toBe('0')
+ expect(parts.fractionalPart).toBe('00000001')
+ })
+})
+
describe('formatSats', () => {
it('should format satoshi values with locale-specific thousands separators', () => {
expect(formatSats(1000)).toBe('1,000')
@@ -542,6 +579,14 @@ describe('formatSats', () => {
expect(formatBtc(21000000)).toBe('2,10,00,000.00000000')
expect(formatSats(2100000000000000)).toBe('2,10,00,00,00,00,00,000')
})
+ withRuntimeLocale('it-IT', () => {
+ expect(formatBtc(21000000)).toBe('21.000.000,00000000')
+ expect(formatSats(2100000000000000)).toBe('2.100.000.000.000.000')
+ })
+ withRuntimeLocale('ar-EG', () => {
+ expect(formatBtc(21000000)).toBe('٢١٬٠٠٠٬٠٠٠٫٠٠٠٠٠٠٠٠')
+ expect(formatSats(2100000000000000)).toBe('٢٬١٠٠٬٠٠٠٬٠٠٠٬٠٠٠٬٠٠٠')
+ })
})
})
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index db844240..7ebd0105 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -157,12 +157,50 @@ export const isValidInteger = (val: unknown): val is number => {
return Number.isSafeInteger(val)
}
+const BTC_NUMBER_FORMAT_OPTIONS: Intl.NumberFormatOptions = {
+ minimumFractionDigits: 8,
+ maximumFractionDigits: 8,
+ roundingMode: 'trunc',
+}
+
export const formatBtc = (value: number) => {
- return value.toLocaleString(undefined, {
- minimumFractionDigits: 8,
- maximumFractionDigits: 8,
- roundingMode: 'trunc',
- })
+ return value.toLocaleString(undefined, BTC_NUMBER_FORMAT_OPTIONS)
+}
+
+export interface BtcParts {
+ /** The sign character ('-') or undefined for positive */
+ sign: string | undefined
+ /** Integer digits without grouping (e.g. "12345") */
+ integerPart: string
+ /** Fraction digits (e.g. "67890123") */
+ fractionalPart: string
+ /** Full locale-formatted string for display attributes */
+ formatted: string
+}
+
+/**
+ * Decompose a BTC value into semantic parts using Intl.NumberFormat.formatToParts().
+ * Handles all locales including it-IT (12.345,67890123) and ar-EG (١٢٬٣٤٥٫٦٧٨٩٠١٢٣).
+ */
+export const getBtcParts = (value: number): BtcParts => {
+ const parts = new Intl.NumberFormat(undefined, BTC_NUMBER_FORMAT_OPTIONS).formatToParts(value)
+
+ const sign = parts.find((p) => p.type === 'minusSign')?.value
+
+ const integerPart =
+ parts
+ .filter((p) => p.type === 'integer')
+ .map((p) => p.value)
+ .join('') || '0'
+
+ const fractionalPart = parts
+ .filter((p) => p.type === 'fraction')
+ .map((p) => p.value)
+ .join('')
+
+ const formatted = parts.map((p) => p.value).join('')
+
+ return { sign, integerPart, fractionalPart, formatted }
}
export const formatSats = (value: number) => {