diff --git a/frontend/cypress/e2e/mainnet/calculator.spec.ts b/frontend/cypress/e2e/mainnet/calculator.spec.ts index b09dff154..6866873fe 100644 --- a/frontend/cypress/e2e/mainnet/calculator.spec.ts +++ b/frontend/cypress/e2e/mainnet/calculator.spec.ts @@ -83,22 +83,28 @@ describe('Calculator', () => { }); describe('bitcoin input updates fiat and sats', () => { - it('updates fiat and sats when entering 0.5 BTC', () => { - const expectedFiat = Math.round(MOCK_BTC_PRICE_USD * 0.5 * 100) / 100; - cy.get('input[formControlName="bitcoin"]').clear().type('0.5'); - cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '50000000'); + it('updates fiat and sats when entering 0.33 BTC', () => { + const expectedFiat = Math.round(MOCK_BTC_PRICE_USD * 0.33 * 100) / 100; // 40740.48 USD + cy.get('input[formControlName="bitcoin"]').clear().type('0.33'); + cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '33000000'); cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => { const fiat = parseFloat(String(fiatVal).replace(/,/g, '')); expect(fiat).to.equal(expectedFiat); }); + cy.get('.fiat-text').invoke('text').then((text) => { + expect(text.trim()).to.equal('$40,740.48'); + }); }); it('updates fiat and sats when entering 1 sat (0.00000001 BTC)', () => { - const expectedFiat = (MOCK_BTC_PRICE_USD / 100_000_000 * 100 / 100).toFixed(8); + const expectedFiat = (MOCK_BTC_PRICE_USD / 100_000_000 * 100 / 100); cy.get('input[formControlName="bitcoin"]').clear().type('0.00000001'); cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '1'); cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => { - expect(String(fiatVal)).to.equal(expectedFiat); + expect(String(fiatVal)).to.equal(String(expectedFiat)); + }); + cy.get('.fiat-text').invoke('text').then((text) => { + expect(text.trim()).to.equal('$0.00'); }); }); }); @@ -220,6 +226,9 @@ describe('Calculator', () => { cy.get('.symbol').invoke('text').then((text) => { expect(text.replace(/,/g, '')).to.include(String(MOCK_BTC_PRICE_JPY)); }); + cy.get('.fiat-text').invoke('text').then((text) => { + expect(text.trim()).to.equal('¥11,057,757'); + }); }); it('displays 1 BTC with correct sats and fiat in JPY', () => { @@ -234,13 +243,17 @@ describe('Calculator', () => { }); it('updates fiat and sats when entering 0.5 BTC in JPY', () => { - const expectedFiat = Math.round(MOCK_BTC_PRICE_JPY * 0.5 * 100) / 100; + const expectedFiat = Math.round(MOCK_BTC_PRICE_JPY * 0.5) cy.get('input[formControlName="bitcoin"]').clear().type('0.5'); cy.get('input[formControlName="satoshis"]').invoke('val').should('equal', '50000000'); cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => { const fiat = parseFloat(String(fiatVal).replace(/,/g, '')); expect(fiat).to.equal(expectedFiat); }); + cy.get('.fiat-text').invoke('text').then((text) => { + expect(text.trim()).to.equal('¥5,528,879'); + }); + }); it('updates BTC and sats when entering fiat value in JPY', () => { @@ -258,13 +271,16 @@ describe('Calculator', () => { it('updates BTC and fiat when entering 10000 sats in JPY', () => { const satsAmount = 10000; - const expectedFiat = Math.round((satsAmount / 100_000_000) * MOCK_BTC_PRICE_JPY * 100) / 100; + const expectedFiat = Math.round((satsAmount / 100_000_000) * MOCK_BTC_PRICE_JPY); cy.get('input[formControlName="satoshis"]').clear().type(String(satsAmount)); cy.get('input[formControlName="bitcoin"]').invoke('val').should('equal', '0.00010000'); cy.get('input[formControlName="fiat"]').invoke('val').then((fiatVal) => { const fiat = parseFloat(String(fiatVal).replace(/,/g, '')); expect(fiat).to.equal(expectedFiat); }); + cy.get('.fiat-text').invoke('text').then((text) => { + expect(text.trim()).to.equal('¥1,106'); + }); }); }); diff --git a/frontend/cypress/e2e/mainnet/fiat-currency-formatting.spec.ts b/frontend/cypress/e2e/mainnet/fiat-currency-formatting.spec.ts new file mode 100644 index 000000000..90e17d032 --- /dev/null +++ b/frontend/cypress/e2e/mainnet/fiat-currency-formatting.spec.ts @@ -0,0 +1,275 @@ +const baseModule = Cypress.env('BASE_MODULE'); + +// Helper to select "Fiat" from the BTC/sats/Fiat amount selector dropdown +const selectFiatMode = () => { + cy.get('app-amount-selector').first().scrollIntoView(); + cy.get('app-amount-selector select').first().select('fiat'); +}; + +// Helper to select currency from the fiat selector dropdown +const selectCurrency = (currency: 'USD' | 'JPY') => { + cy.get('app-fiat-selector').first().scrollIntoView(); + cy.get('app-fiat-selector').first().click(); + cy.get('app-fiat-selector select').first().select(currency); +}; + +describe('Fiat Currency Formatting', () => { + + if (baseModule === 'mempool') { + + describe('Dashboard', () => { + beforeEach(() => { + cy.visit('/'); + cy.waitForSkeletonGone(); + selectFiatMode(); + cy.get('.latest-transactions', { timeout: 10000 }).should('exist'); + cy.get('.table-cell-fiat', { timeout: 10000 }).should('have.length.at.least', 1); + }); + + describe('USD formatting', () => { + beforeEach(() => { + selectCurrency('USD'); + }); + + it('displays USD values with correct currency symbol', () => { + cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => { + const trimmedText = text.trim(); + expect(trimmedText).to.include('$'); + }); + }); + + it('displays USD values with proper decimal format', () => { + cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => { + const trimmedText = text.trim(); + expect(trimmedText).to.match(/^\$[\d,]+(\.\d{2})?$/); + }); + }); + }); + + describe('JPY formatting', () => { + beforeEach(() => { + selectCurrency('JPY'); + cy.get('.table-cell-fiat').eq(1).should(($el) => { + expect($el.text()).to.include('¥'); + }); + }); + + it('displays JPY values with yen symbol', () => { + cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => { + const trimmedText = text.trim(); + expect(trimmedText).to.include('¥'); + }); + }); + + it('displays JPY values without decimal places', () => { + cy.get('.table-cell-fiat').eq(1).invoke('text').then((text) => { + const trimmedText = text.trim(); + expect(trimmedText).to.not.match(/\.\d+$/); + }); + }); + + it('formats all JPY values correctly without decimals', () => { + cy.get('.table-cell-fiat').each(($el) => { + const text = $el.text().trim(); + if (text.includes('¥')) { + expect(text).to.not.match(/\.\d+/); + } + }); + }); + }); + + describe('currency switching', () => { + it('correctly formats when switching from USD to JPY', () => { + selectCurrency('USD'); + cy.get('.table-cell-fiat').eq(1).invoke('text').then((usdText) => { + expect(usdText.trim()).to.include('$'); + }); + + selectCurrency('JPY'); + cy.get('.table-cell-fiat').eq(1).should(($el) => { + const text = $el.text().trim(); + expect(text).to.include('¥'); + expect(text).to.not.match(/\.\d+$/); + }); + }); + + it('correctly formats when switching from JPY back to USD', () => { + selectCurrency('JPY'); + cy.get('.table-cell-fiat').eq(1).should(($el) => { + expect($el.text()).to.include('¥'); + }); + + selectCurrency('USD'); + cy.get('.table-cell-fiat').eq(1).should(($el) => { + expect($el.text().trim()).to.include('$'); + }); + }); + }); + }); + + describe('Transaction Page', () => { + beforeEach(() => { + cy.visit('/tx/dd0faea1e9acd5bd812e5130c0912b62d6b63d04bac4558f2e07270ac613a8f2'); + cy.waitForSkeletonGone(); + }); + + it('displays USD fiat values with dollar symbol', () => { + selectCurrency('USD'); + selectFiatMode(); + + cy.get('app-transaction-details .fiat').invoke('text').then((text) => { + expect(text.trim()).to.include('$'); + }); + + cy.get('app-transactions-list .fiat', { timeout: 10000 }).first().invoke('text').then((text) => { + expect(text.trim()).to.include('$'); + }); + }); + + it('displays JPY fiat values without decimals', () => { + selectCurrency('JPY'); + selectFiatMode(); + + + cy.get('app-transaction-details .fiat').invoke('text').then((text) => { + const trimmedText = text.trim(); + expect(trimmedText).to.include('¥'); + expect(trimmedText).to.not.match(/\.\d+/); + }); + + cy.get('app-transactions-list .fiat').first().should(($el) => { + const text = $el.text().trim(); + expect(text).to.include('¥'); + expect(text).to.not.match(/\.\d+/); + }); + }); + }); + + describe('Block Page', () => { + beforeEach(() => { + cy.viewport(1080, 1920); // force taller screen to avoid scrollbars + cy.intercept('/api/v1/blocks/0').as('blocks-0'); + cy.intercept('/api/v1/blocks/10').as('blocks-10'); + cy.intercept('/api/v1/blocks/20').as('blocks-20'); + cy.intercept('/api/txs/outspends*').as('outspends'); + cy.visit('/block/100000'); + }); + + it('displays USD fiat values in block reward with dollar symbol', () => { + cy.wait('@outspends').then(() => { + cy.waitUntil(() => cy.get('.fiat').each(($el) => $el.is(':visible') && $el.text().trim().includes('$'))); + selectCurrency('USD'); + selectFiatMode(); + cy.get('.fiat').each(($el) => { + expect($el.text().trim()).to.include('$'); + }); + }); + }); + + it('displays JPY fiat values without decimals', () => { + cy.wait('@outspends').then(() => { + cy.waitUntil(() => cy.get('.fiat').each(($el) => $el.is(':visible') && $el.text().trim().includes('$'))); + selectCurrency('JPY'); + selectFiatMode(); + cy.get('.fiat').each(($el) => { + expect($el.text().trim()).to.include('¥'); + expect($el.text().trim()).to.not.match(/\.\d+/); + }); + }); + }) + }); + + describe('Address Page', () => { + beforeEach(() => { + cy.visit('/address/1wizaAB16Wrua9V58uNvqktyq2LBLtYso'); + }); + + it('displays USD fiat values with dollar symbol', () => { + selectCurrency('USD'); + selectFiatMode(); + cy.get('app-amount .fiat', { timeout: 10000 }).each(($el) => { + expect($el.text().trim()).to.include('$'); + }); + }); + + it('displays JPY fiat values without decimals', () => { + selectCurrency('JPY'); + selectFiatMode(); + cy.get('app-amount .fiat').first().should(($el) => { + const text = $el.text().trim(); + expect(text).to.include('¥'); + expect(text).to.not.match(/\.\d+/); + }); + }); + }); + + describe('Calculator Page', () => { + beforeEach(() => { + cy.visit('/tools/calculator'); + cy.waitForSkeletonGone(); + cy.get('input[formControlName="bitcoin"]', { timeout: 10000 }).should('be.visible'); + }); + + it('displays USD price with dollar symbol', () => { + selectCurrency('USD'); + cy.get('.symbol').invoke('text').then((text) => { + expect(text.trim()).to.include('$'); + }); + }); + + it('displays JPY price without decimals after switching currency', () => { + selectCurrency('JPY'); + cy.get('.symbol').should(($el) => { + const text = $el.text().trim(); + expect(text).to.include('¥'); + }); + + // Enter a value in BTC to see the fiat conversion + cy.get('input[formControlName="bitcoin"]').clear().type('1'); + + // Check that the fiat output doesn't have decimals for JPY + cy.get('input[formControlName="fiat"]').should(($el) => { + const value = $el.val() as string; + // JPY should not have decimal places in large values + expect(value).to.not.match(/\.\d+$/); + }); + }); + }); + + describe('Mempool Block Tooltip', () => { + beforeEach(() => { + cy.visit('/'); + cy.waitForSkeletonGone(); + selectFiatMode(); + }); + + it('displays USD fiat values in mempool block', () => { + selectCurrency('USD'); + cy.get('#mempool-block-0').scrollIntoView(); + cy.get('#mempool-block-0').click(); + + cy.waitForSkeletonGone(); + + cy.get('app-mempool-block .fiat').each(($el) => { + expect($el.text().trim()).to.include('$'); + }); + }); + + it('displays JPY fiat values in mempool block', () => { + selectCurrency('JPY'); + cy.get('#mempool-block-0').scrollIntoView(); + cy.get('#mempool-block-0').click(); + + cy.waitForSkeletonGone(); + + cy.get('app-mempool-block .fiat').invoke('text').then((text) => { + expect(text.trim()).to.include('¥'); + expect(text).to.not.match(/\.\d+$/); + }); + }); + }); + + } else { + it.skip(`Tests cannot be run on the selected BASE_MODULE ${baseModule}`); + } +}); diff --git a/frontend/src/app/components/calculator/calculator.component.ts b/frontend/src/app/components/calculator/calculator.component.ts index d38039d37..5d749bc7b 100644 --- a/frontend/src/app/components/calculator/calculator.component.ts +++ b/frontend/src/app/components/calculator/calculator.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Inject, LOCALE_ID, OnInit } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; import { combineLatest, Observable } from 'rxjs'; import { map, switchMap } from 'rxjs/operators'; @@ -20,12 +20,15 @@ export class CalculatorComponent implements OnInit { form: FormGroup; currentPrice = 0; isMaxSupply = false; + currentCurrency = 'USD'; + currencyDecimals = 2; currency$ = this.stateService.fiatCurrency$; price$: Observable; lastFiatPrice$: Observable; constructor( + @Inject(LOCALE_ID) private locale: string, private stateService: StateService, private formBuilder: FormBuilder, private websocketService: WebsocketService, @@ -47,6 +50,8 @@ export class CalculatorComponent implements OnInit { this.price$ = this.currency$.pipe( switchMap((result) => { currency = result; + this.currentCurrency = result; + this.updateCurrencyDecimals(); return this.stateService.conversions$.asObservable(); }), map((conversions) => { @@ -132,8 +137,11 @@ export class CalculatorComponent implements OnInit { if (name === 'bitcoin' && this.countDecimals(sanitizedValue) > 8) { sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 8); } - if (name === 'fiat' && this.countDecimals(sanitizedValue) > 2) { - sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, 2); + if (name === 'fiat') { + const decimals = this.getCurrencyDecimals(); + if (this.countDecimals(sanitizedValue) > decimals) { + sanitizedValue = this.toFixedWithoutRounding(sanitizedValue, decimals); + } } if (sanitizedValue === '') { sanitizedValue = '0'; @@ -176,12 +184,20 @@ export class CalculatorComponent implements OnInit { } formatFiat(num: number): string | number { + // Get the number of decimal places for the current currency + const decimals = this.getCurrencyDecimals(); + + if (decimals === 0) { + return Math.round(num); + } + if (Math.abs(num) >= 1000) { - // For values >= 1000: show 2 decimals, or 0 if whole number + // For values >= 1000: show currency-specific decimals, or 0 if whole number if (num % 1 === 0) { return Math.round(num); } - return (Math.round(num * 100) / 100).toFixed(2); + const factor = Math.pow(10, decimals); + return (Math.round(num * factor) / factor).toFixed(decimals); } if (num % 1 === 0) { return Math.round(num); @@ -190,6 +206,23 @@ export class CalculatorComponent implements OnInit { if (Math.abs(num) < 1 && num !== 0) { return num.toFixed(8); } - return (Math.round(num * 100) / 100).toFixed(2); + const factor = Math.pow(10, decimals); + return (Math.round(num * factor) / factor).toFixed(decimals); + } + + private updateCurrencyDecimals(): void { + try { + const formatter = new Intl.NumberFormat(this.locale, { + style: 'currency', + currency: this.currentCurrency + }); + this.currencyDecimals = formatter.resolvedOptions().maximumFractionDigits; + } catch { + this.currencyDecimals = 2; // Default to 2 decimal places + } + } + + getCurrencyDecimals(): number { + return this.currencyDecimals; } } diff --git a/frontend/src/app/shared/pipes/fiat-currency.pipe.ts b/frontend/src/app/shared/pipes/fiat-currency.pipe.ts index ffaef26d3..b3f3cb193 100644 --- a/frontend/src/app/shared/pipes/fiat-currency.pipe.ts +++ b/frontend/src/app/shared/pipes/fiat-currency.pipe.ts @@ -21,17 +21,7 @@ export class FiatCurrencyPipe implements PipeTransform { } transform(num: number, ...args: any[]): unknown { - const digits = args[0] || 1; const currency = args[1] || this.currency || 'USD'; - - if (Math.abs(num) >= 1000) { - // Check if decimals are exactly 0 - if (num % 1 === 0) { - return new Intl.NumberFormat(this.locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(num); - } - return new Intl.NumberFormat(this.locale, { style: 'currency', currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num); - } else { - return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num); - } + return new Intl.NumberFormat(this.locale, { style: 'currency', currency }).format(num); } }