From 3fc3ff3d58e37ef5889b1ceb8d48de08868f5ee9 Mon Sep 17 00:00:00 2001 From: Wu Shuwen Date: Tue, 23 Jun 2026 21:39:32 +0800 Subject: [PATCH] fix: handle locale-specific decimal separators in BTC balance (#1290) --- src/components/ui/jam/Balance.tsx | 10 +++---- src/lib/utils.test.ts | 49 +++++++++++++++++++++++++++++-- src/lib/utils.ts | 48 ++++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 13 deletions(-) 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) => {