mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
fix: handle locale-specific decimal separators in BTC balance (#1290)
This commit is contained in:
parent
08890f6dda
commit
3fc3ff3d58
3 changed files with 94 additions and 13 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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('٢٬١٠٠٬٠٠٠٬٠٠٠٬٠٠٠٬٠٠٠')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue