fix: handle locale-specific decimal separators in BTC balance (#1290)
Some checks are pending
Build / build (v26.3.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run

This commit is contained in:
Wu Shuwen 2026-06-23 21:39:32 +08:00 committed by GitHub
parent 08890f6dda
commit 3fc3ff3d58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 94 additions and 13 deletions

View file

@ -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 && <span>{sign}</span>}
<span className={styles.integerPart}>{integerPart}</span>

View file

@ -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('٢٬١٠٠٬٠٠٠٬٠٠٠٬٠٠٠٬٠٠٠')
})
})
})

View file

@ -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) => {