From d781437ce40f0014b1d5c8567bc99c4c5e3dbd08 Mon Sep 17 00:00:00 2001 From: rodribp Date: Thu, 2 Jul 2026 03:29:49 -0600 Subject: [PATCH 1/3] Add: address-focused view in transactions --- .../components/payment/payment.component.html | 172 +++++++ .../components/payment/payment.component.scss | 188 +++++++ .../components/payment/payment.component.ts | 470 ++++++++++++++++++ .../app/components/payment/payment.module.ts | 50 ++ .../app/components/tracker/tracker.module.ts | 3 + frontend/src/app/master-page.module.ts | 10 + frontend/src/app/route-guards.ts | 11 +- 7 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/components/payment/payment.component.html create mode 100644 frontend/src/app/components/payment/payment.component.scss create mode 100644 frontend/src/app/components/payment/payment.component.ts create mode 100644 frontend/src/app/components/payment/payment.module.ts diff --git a/frontend/src/app/components/payment/payment.component.html b/frontend/src/app/components/payment/payment.component.html new file mode 100644 index 000000000..bce46564e --- /dev/null +++ b/frontend/src/app/components/payment/payment.component.html @@ -0,0 +1,172 @@ +
+ + @if (!error || loadingCachedTx) { + +
+

Bitcoin payment

+ + + + + + + +
+
+ +
+
+ @if (isMobile) { +
+ + + + + + +
+
+ } @else { +
+ + + + + +
+
+
+ +
+ } +
+
+ + + + } + + @if (error && !loadingCachedTx) { + + Error loading transaction data. + + } + +
+ + + + + + + + + @if (isValidView) { + + Address + + + + + } @else { + + } + + + @if (tx) { + + Amount + + + + + + + + } @else { + + } + + + @if (tx && !replaced) { + + Status + + @if (!tx.status?.confirmed) { + + @if (eta.blocks >= 7) { + Not any time soon + } @else { + + } + + + + + } @else { + + } + + + } @else if (replaced) { + } @else { + + } + + + + @if (settled) { + Settled + } @else if (isValidView && confsRequired > 1) { +
+ Confirmed + + @for (conf of confsRequiredArr; track $index) { + + + + + + } + +
+ } @else { + + } +
+ + + @if (isValidView) { +
+ +
+ } +
+ + + @if (replaced) { + + } @else if (tx && !isCached) { +
+ +
+ } @else { + + } +
+ + + + + + \ No newline at end of file diff --git a/frontend/src/app/components/payment/payment.component.scss b/frontend/src/app/components/payment/payment.component.scss new file mode 100644 index 000000000..dca6e4682 --- /dev/null +++ b/frontend/src/app/components/payment/payment.component.scss @@ -0,0 +1,188 @@ + +.title-block { + flex-wrap: wrap; + align-items: baseline; + + @media (min-width: 650px) { + flex-direction: row; + } + + h1 { + margin: 0; + margin-right: 15px; + line-height: 1; + } +} + +.tx-link { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: baseline; + width: 0; + max-width: 100%; + margin-top: 8px; + @media (min-width: 651px) { + flex-grow: 1; + margin-right: 1em; + top: 1px; + position: relative; + } + @media (max-width: 650px) { + width: 100%; + order: 3; + } + + .txid { + width: 200px; + min-width: 200px; + flex-grow: 1; + } +} + +.container-buttons { + align-self: center; +} + +.row { + flex-direction: column; + @media (min-width: 850px) { + flex-direction: row; + } +} + +.td-width { + width: 150px; + @media (max-width: 768px) { + width: 100px; + } +} + +.table { + width: 100%; + table-layout: fixed; + tr td { + padding: 0.75rem 0.5rem; + @media (min-width: 576px) { + padding: 0.75rem 0.75rem; + } + &:last-child { + text-align: right; + @media (min-width: 850px) { + text-align: left; + } + } + + &.wrap-cell { + white-space: normal; + } + } +} + +.address ::ng-deep .truncate { + justify-content: end !important; + @media (min-width: 850px) { + justify-content: start !important; + } +} + +.fiat { + display: block; + @media (min-width: 768px) { + display: inline-block; + margin-left: 15px; + text-align: left; + } +} + +.badge { + position: relative; + top: -1px; +} + +.qr-col { + display: flex; + align-items: center; + justify-content: center; +} + +.qr-wrapper { + display: flex; + justify-content: center; + padding: 10px 10px 5px 10px; + background-color: #ffffff; + border-radius: 4px; + width: fit-content; + margin: 0 auto 0 auto; +} + +.details-link { + margin-top: 20px; + text-align: center; +} + +.tracker-bar { + padding: 1em 0.5em 0; + margin-bottom: 1em; + width: 100%; +} + +.settled { + color: var(--green); + opacity: 0.8; +} + +.confs-cell { + display: flex; + gap: 1em; + flex-direction: row; + align-items: center; + justify-content: end; + + + @media (min-width: 850px) { + justify-content: start; + } +} + +.confirmations { + fill: var(--green); + fill-opacity: 0.8; + + &.pending { + fill-opacity: 0.25; + &.animate { + animation: pulse 2s ease-in-out infinite; + } + } +} + +.alert-replaced { + display: flex; + flex-direction: column; + min-width: 0; + background: var(--red); + margin-bottom: 1em; + padding: 0.5em; + border-radius: 0.5em; + + @media (max-width: 850px) { + margin-top: 1em; + } + + a { + overflow-wrap: anywhere; + } +} + +@keyframes pulse { + 0% { + fill-opacity: 0.25; + } + 50% { + fill-opacity: 0.6; + } + 100% { + fill-opacity: 0.25; + } +} diff --git a/frontend/src/app/components/payment/payment.component.ts b/frontend/src/app/components/payment/payment.component.ts new file mode 100644 index 000000000..f3b02bdbf --- /dev/null +++ b/frontend/src/app/components/payment/payment.component.ts @@ -0,0 +1,470 @@ +import { Component, OnInit, OnDestroy, HostListener, ChangeDetectorRef, Inject } from '@angular/core'; +import { ElectrsApiService } from '@app/services/electrs-api.service'; +import { ActivatedRoute, ParamMap, Router } from '@angular/router'; +import { switchMap, filter, catchError, map, startWith, distinctUntilChanged, tap } from 'rxjs/operators'; +import { Transaction } from '@interfaces/electrs.interface'; +import { of, merge, Subscription, Observable, combineLatest, BehaviorSubject, Subject } from 'rxjs'; +import { StateService } from '@app/services/state.service'; +import { AudioService } from '@app/services/audio.service'; +import { CacheService } from '@app/services/cache.service'; +import { WebsocketService } from '@app/services/websocket.service'; +import { SeoService } from '@app/services/seo.service'; +import { seoDescriptionNetwork } from '@app/shared/common.utils'; +import { getUnacceleratedFeeRate } from '@app/shared/transaction.utils'; +import { BlockExtended, MempoolPosition, AccelerationPosition, RbfTree } from '@interfaces/node-api.interface'; +import { RelativeUrlPipe } from '@app/shared/pipes/relative-url/relative-url.pipe'; +import { ZONE_SERVICE } from '@app/injection-tokens'; +import { MiningService, MiningStats } from '@app/services/mining.service'; +import { ETA, EtaService } from '@app/services/eta.service'; +import { getRegex } from '@app/shared/regex.utils'; +import { TrackerStage } from '@components/tracker/tracker-bar.component'; +import { ApiService } from '@app/services/api.service'; + +@Component({ + selector: 'app-payment', + templateUrl: './payment.component.html', + styleUrls: ['./payment.component.scss'], + standalone: false, +}) +export class PaymentComponent implements OnInit, OnDestroy { + network = ''; + tx: Transaction; + txId: string; + mempoolPosition: MempoolPosition; + accelerationPositions: AccelerationPosition[]; + latestBlock: BlockExtended; + isLoadingTx = true; + error: any = undefined; + waitingForTransaction = false; + isMobile: boolean; + isValidView = false; + rbfTransaction: Transaction | null; + + destination = ''; + confsRequired = 1; + amount = 0; + confirmations = 0; + settled = false; + replaced = false; + trackerStage: TrackerStage = 'waiting'; + + miningStats: MiningStats; + ETA$: Observable; + txChanged$ = new BehaviorSubject(false); + isAccelerated$ = new BehaviorSubject(false); + + isCached: boolean; + rbfInfo: RbfTree; + fetchRbfHistory$ = new Subject(); + fetchCachedTx$ = new Subject(); + loadingCachedTx: boolean; + + subscription: Subscription; + networkChangedSubscription: Subscription; + blocksSubscription: Subscription; + mempoolPositionSubscription: Subscription; + txConfirmedSubscription: Subscription; + txReplacedSubscription: Subscription; + fetchRbfSubscription: Subscription; + fetchCachedTxSubscription: Subscription; + txRbfInfoSubscription: Subscription; + latestReplacement: string; + + constructor( + private route: ActivatedRoute, + private router: Router, + private relativeUrlPipe: RelativeUrlPipe, + private apiService: ApiService, + private electrsApiService: ElectrsApiService, + public stateService: StateService, + private audioService: AudioService, + private cacheService: CacheService, + private websocketService: WebsocketService, + private seoService: SeoService, + private miningService: MiningService, + private etaService: EtaService, + private cd: ChangeDetectorRef, + @Inject(ZONE_SERVICE) private zoneService: any, + ) {} + + ngOnInit(): void { + this.onResize(); + + if (!this.stateService.isLiquid()) { + this.miningService.getMiningStats('1m').subscribe(stats => { + this.miningStats = stats; + }); + } + + this.websocketService.want(['blocks', 'mempool-blocks']); + + this.networkChangedSubscription = this.stateService.networkChanged$.subscribe((network) => { + this.network = network; + }); + + this.blocksSubscription = this.stateService.blocks$.subscribe((blocks) => { + this.latestBlock = blocks[0]; + this.updateConfirmations(); + this.cd.markForCheck(); + }); + + this.mempoolPositionSubscription = this.stateService.mempoolTxPosition$.subscribe(txPosition => { + if (txPosition && txPosition.txid === this.txId && txPosition.position) { + this.mempoolPosition = txPosition.position; + this.accelerationPositions = txPosition.accelerationPositions; + this.isAccelerated$.next(!!(this.tx?.acceleration || txPosition.position?.accelerated)); + if (this.tx && !this.tx.status.confirmed) { + this.markBlock(); + } + } else { + this.mempoolPosition = null; + this.accelerationPositions = null; + this.isAccelerated$.next(false); + } + }); + + this.fetchRbfSubscription = this.fetchRbfHistory$ + .pipe( + switchMap((txId) => + this.apiService + .getRbfHistory$(txId) + ), + catchError(() => { + return of(null); + }) + ).subscribe((rbfResponse) => { + this.rbfInfo = rbfResponse?.replacements; + if (this.rbfInfo) { + // link to the latest pending version + this.latestReplacement = this.rbfInfo.tx.txid; + // or traverse the rbf tree to find a confirmed version + if (this.rbfInfo.mined) { + const stack = [this.rbfInfo]; + let found = false; + while (stack.length && !found) { + const top = stack.pop(); + if (top?.tx.mined) { + found = true; + this.latestReplacement = top.tx.txid; + break; + } else { + stack.push(...top.replaces); + } + } + } + } + }); + + this.fetchCachedTxSubscription = this.fetchCachedTx$ + .pipe( + tap(() => { + this.loadingCachedTx = true; + }), + switchMap((txId) => + this.apiService + .getRbfCachedTx$(txId) + ), + catchError(() => { + return of(null); + }) + ).subscribe((tx) => { + this.loadingCachedTx = false; + if (!tx) { + this.seoService.logSoft404(); + return; + } + this.seoService.clearSoft404(); + + if (!this.tx) { + this.tx = tx; + this.isCached = true; + if (tx.fee === undefined) { + this.tx.fee = 0; + } + this.tx.feePerVsize = tx.fee / (tx.weight / 4); + this.isLoadingTx = false; + this.error = undefined; + this.waitingForTransaction = false; + + if (!this.isValidDestination(tx)) { + this.viewFullDetails(); + return; + } + + this.fetchRbfHistory$.next(this.tx.txid); + this.txRbfInfoSubscription = this.stateService.txRbfInfo$.subscribe((rbfInfo) => { + if (this.tx) { + this.rbfInfo = rbfInfo; + } + }); + this.setAmount(); + this.txChanged$.next(true); + } + }); + + this.subscription = this.zoneService.wrapObservable(this.route.paramMap + .pipe( + switchMap((params: ParamMap) => { + this.txId = params.get('id'); + this.seoService.setTitle($localize`:@@bisq.transaction.browser-title:Transaction: ${this.txId}:INTERPOLATION:`); + const network = this.stateService.network === 'liquid' || this.stateService.network === 'liquidtestnet' ? 'Liquid' : 'Bitcoin'; + const seoDescription = seoDescriptionNetwork(this.stateService.network); + this.seoService.setDescription($localize`:@@meta.description.bitcoin.transaction:Get real-time status, addresses, fees, script info, and more for ${network}${seoDescription} transaction with txid ${this.txId}.`); + this.resetTransaction(); + + return merge( + of(true), + this.stateService.connectionState$.pipe( + filter((state) => state === 2 && this.tx && !this.tx.status?.confirmed) + ) + ); + }), + switchMap(() => { + let transactionObservable$: Observable; + const cached = this.cacheService.getTxFromCache(this.txId); + if (cached && cached.fee !== -1) { + transactionObservable$ = of(cached); + } else { + transactionObservable$ = this.electrsApiService + .getTransaction$(this.txId) + .pipe(catchError(this.handleLoadElectrsTransactionError.bind(this))); + } + return merge(transactionObservable$, this.stateService.mempoolTransactions$); + }), + )) + .subscribe((tx: Transaction) => { + if (!tx) { + this.fetchCachedTx$.next(this.txId); + this.seoService.logSoft404(); + return; + } + this.seoService.clearSoft404(); + + this.tx = tx; + this.isCached = false; + this.isAccelerated$.next(!!(tx.acceleration || this.mempoolPosition?.accelerated)); + if (tx.fee === undefined) { + this.tx.fee = 0; + } + this.tx.feePerVsize = tx.fee / (tx.weight / 4); + this.isLoadingTx = false; + this.error = undefined; + this.waitingForTransaction = false; + + // The payment view only makes sense for a real output of this transaction. + // Otherwise fall back to the full transaction page. + if (!this.isValidDestination(tx)) { + this.viewFullDetails(); + return; + } + + if (!tx.status.confirmed) { + this.trackerStage = 'pending'; + } else { + this.trackerStage = 'confirmed'; + } + + this.websocketService.startTrackTransaction(tx.txid); + this.setAmount(); + this.updateConfirmations(); + this.txChanged$.next(true); + this.markBlock(); + + this.cd.detectChanges(); + }, + (error) => { + this.error = error; + this.seoService.logSoft404(); + this.isLoadingTx = false; + }); + + this.txConfirmedSubscription = this.stateService.txConfirmed$.subscribe(([txConfirmed, block]) => { + if (txConfirmed && this.tx && !this.tx.status.confirmed && txConfirmed === this.tx.txid) { + this.tx.status = { + confirmed: true, + block_height: block.height, + block_hash: block.id, + block_time: block.timestamp, + }; + this.txChanged$.next(true); + this.updateConfirmations(); + this.stateService.markBlock$.next({ blockHeight: block.height }); + this.trackerStage = 'confirmed'; + this.audioService.playSound('magic'); + this.cd.markForCheck(); + } + }); + + this.txReplacedSubscription = this.stateService.txReplaced$.subscribe((rbfTx) => { + if (!this.tx) { + this.error = new Error(); + this.loadingCachedTx = false; + this.waitingForTransaction = false; + } + + this.rbfTransaction = rbfTx; + this.replaced = true; + this.trackerStage = 'replaced'; + if (!this.rbfInfo && rbfTx) { + this.latestReplacement = rbfTx.txid; + } + this.stateService.markBlock$.next({}); + + if (rbfTx && !this.tx) { + this.fetchCachedTx$.next(this.txId); + } + this.cd.markForCheck(); + }); + + this.ETA$ = combineLatest([ + this.stateService.mempoolTxPosition$.pipe(startWith(null)), + this.stateService.mempoolBlocks$.pipe(startWith(null)), + this.stateService.difficultyAdjustment$.pipe(startWith(null)), + this.isAccelerated$, + this.txChanged$, + ]).pipe( + map(([position, mempoolBlocks, da, isAccelerated]) => { + if (!this.tx || this.tx.status?.confirmed || !position || position.txid !== this.tx.txid) { + return null; + } + return this.etaService.calculateETA( + this.network, + this.tx, + mempoolBlocks, + position, + da, + this.miningStats, + isAccelerated, + this.accelerationPositions, + ); + }), + distinctUntilChanged((prev: ETA | null, curr: ETA | null) => { + return prev === curr || (prev && curr && prev.time === curr.time && prev.blocks === curr.blocks); + }), + tap((eta) => { + if (this.replaced) { + this.trackerStage = 'replaced'; + } else if (eta?.blocks === 0) { + this.trackerStage = 'next'; + } else if (eta?.blocks < 3){ + this.trackerStage = 'soon'; + } else { + this.trackerStage = 'pending'; + } + }) + ); + } + + isValidDestination(tx: Transaction): boolean { + const network = (this.network || 'mainnet') as any; + if (!this.destination || !getRegex('address', network).test(this.destination)) { + return false; + } + + this.isValidView = tx?.vout?.some(vout => vout.scriptpubkey_address === this.destination); + return this.isValidView; + } + + setAmount(): void { + this.amount = (this.tx?.vout || []).reduce((total, vout) => { + return vout.scriptpubkey_address === this.destination ? total + vout.value : total; + }, 0); + } + + updateConfirmations(): void { + if (this.tx?.status?.confirmed && this.latestBlock?.height != null) { + this.confirmations = Math.max(1, this.latestBlock.height - this.tx.status.block_height + 1); + } else { + this.confirmations = 0; + } + this.settled = this.confirmations >= this.confsRequired; + } + + markBlock(): void { + if (this.tx?.status?.confirmed) { + this.stateService.markBlock$.next({ blockHeight: this.tx.status.block_height }); + } else if (this.tx) { + const txFeePerVSize = getUnacceleratedFeeRate(this.tx, this.tx.acceleration || this.mempoolPosition?.accelerated); + this.stateService.markBlock$.next({ + txid: this.tx.txid, + txFeePerVSize, + mempoolPosition: this.mempoolPosition, + accelerationPositions: this.accelerationPositions, + }); + } + } + + viewFullDetails(): void { + this.router.navigate([this.relativeUrlPipe.transform('/tx'), this.txId]); + } + + get confsRequiredArr(): number[] { + return Array(this.confsRequired); + } + + handleLoadElectrsTransactionError(error: any): Observable { + if (error.status === 404 && /^[a-fA-F0-9]{64}$/.test(this.txId)) { + this.websocketService.startMultiTrackTransaction(this.txId); + this.waitingForTransaction = true; + } + this.error = error; + this.seoService.logSoft404(); + this.isLoadingTx = false; + return of(false); + } + + getFragment(destination: string, confs: number = 1): string { + return `destination=${destination}&confs=${confs}`; + } + + resetTransaction(): void { + const fragmentParams = new URLSearchParams(this.route.snapshot.fragment || ''); + this.destination = fragmentParams.get('destination') || ''; + const parsedConfs = Math.min(Math.ceil(parseInt((fragmentParams.get('confs') || '1'), 10)), 6); // Capped to 6 confs + this.confsRequired = (!isNaN(parsedConfs) && parsedConfs >= 1) ? parsedConfs : 1; + + this.error = undefined; + this.tx = null; + this.txChanged$.next(true); + this.waitingForTransaction = false; + this.isLoadingTx = true; + this.isValidView = false; + this.replaced = false; + this.rbfTransaction = null; + this.rbfInfo = null; + this.latestReplacement = ''; + this.isCached = false; + this.loadingCachedTx = false; + this.trackerStage = 'waiting'; + this.amount = 0; + this.confirmations = 0; + this.settled = false; + this.mempoolPosition = null; + this.accelerationPositions = null; + this.isAccelerated$.next(false); + this.txRbfInfoSubscription?.unsubscribe(); + this.leaveTransaction(); + } + + leaveTransaction(): void { + this.websocketService.stopTrackingTransaction(); + this.stateService.markBlock$.next({}); + } + + @HostListener('window:resize', ['$event']) + onResize(): void { + this.isMobile = window.innerWidth < 850; + } + + ngOnDestroy(): void { + this.fetchRbfSubscription?.unsubscribe(); + this.fetchCachedTxSubscription?.unsubscribe(); + this.subscription?.unsubscribe(); + this.networkChangedSubscription?.unsubscribe(); + this.blocksSubscription?.unsubscribe(); + this.mempoolPositionSubscription?.unsubscribe(); + this.txConfirmedSubscription?.unsubscribe(); + this.txReplacedSubscription?.unsubscribe(); + this.leaveTransaction(); + } +} diff --git a/frontend/src/app/components/payment/payment.module.ts b/frontend/src/app/components/payment/payment.module.ts new file mode 100644 index 000000000..0840f3271 --- /dev/null +++ b/frontend/src/app/components/payment/payment.module.ts @@ -0,0 +1,50 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Routes, RouterModule } from '@angular/router'; +import { SharedModule } from '@app/shared/shared.module'; +import { PaymentComponent } from '@components/payment/payment.component'; +import { TrackerModule } from '@components/tracker/tracker.module'; + +const routes: Routes = [ + { + path: '', + redirectTo: '/', + pathMatch: 'full', + }, + { + path: ':id', + component: PaymentComponent, + data: { + ogImage: true + } + } +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes) + ], + exports: [ + RouterModule + ] +}) +export class PaymentRoutingModule { } + +@NgModule({ + imports: [ + CommonModule, + PaymentRoutingModule, + SharedModule, + TrackerModule + ], + declarations: [ + PaymentComponent + ] +}) +export class PaymentModule { } + + + + + + diff --git a/frontend/src/app/components/tracker/tracker.module.ts b/frontend/src/app/components/tracker/tracker.module.ts index b41a68bc5..b1c8b8ba6 100644 --- a/frontend/src/app/components/tracker/tracker.module.ts +++ b/frontend/src/app/components/tracker/tracker.module.ts @@ -40,6 +40,9 @@ export class TrackerRoutingModule { } declarations: [ TrackerComponent, TrackerBarComponent, + ], + exports: [ + TrackerBarComponent ] }) export class TrackerModule { } diff --git a/frontend/src/app/master-page.module.ts b/frontend/src/app/master-page.module.ts index 78c46916a..258de80e8 100644 --- a/frontend/src/app/master-page.module.ts +++ b/frontend/src/app/master-page.module.ts @@ -17,6 +17,7 @@ import { ServerStatusComponent } from '@components/server-health/server-status.c import { FaucetComponent } from '@components/faucet/faucet.component'; import { SimpleProofWidgetComponent } from '@components/simpleproof-widget/simpleproof-widget.component'; import { SimpleProofCuboWidgetComponent } from '@components/simpleproof-widget/simpleproof-cubo-widget.component'; +import { PaymentGuard } from '@app/route-guards'; const browserWindow = window || {}; // @ts-ignore @@ -93,6 +94,15 @@ const routes: Routes = [ { path: 'tx', component: StartComponent, + canMatch: [PaymentGuard], + runGuardsAndResolvers: 'always', + data: { preload: true, networkSpecific: true }, + loadChildren: () => import('@components/payment/payment.module').then(m => m.PaymentModule), + }, + { + path: 'tx', + component: StartComponent, + runGuardsAndResolvers: 'always', data: { preload: true, networkSpecific: true }, loadChildren: () => import('@components/transaction/transaction.module').then(m => m.TransactionModule), }, diff --git a/frontend/src/app/route-guards.ts b/frontend/src/app/route-guards.ts index 81cbf03ae..3864ea200 100644 --- a/frontend/src/app/route-guards.ts +++ b/frontend/src/app/route-guards.ts @@ -14,10 +14,19 @@ class GuardService { trackerGuard(route: Route, segments: UrlSegment[]): boolean { const preferredRoute = this.router.getCurrentNavigation()?.extractedUrl.queryParams?.mode; const path = this.router.getCurrentNavigation()?.extractedUrl.root.children.primary.segments; - return (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path)); + return (!this.paymentGuard() && (preferredRoute === 'status' || (preferredRoute !== 'details' && this.navigationService.isInitialLoad())) && window.innerWidth <= 767.98 && !(path.length === 2 && ['push', 'test', 'preview'].includes(path[1].path))); + } + + paymentGuard(route?: Route, segments?: UrlSegment[]): boolean { + const fragmentParams = new URLSearchParams(this.router.getCurrentNavigation()?.extractedUrl.fragment || ''); + return (fragmentParams.has('destination')); } } export const TrackerGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => { return inject(GuardService).trackerGuard(route, segments); }; + +export const PaymentGuard: CanMatchFn = (route: Route, segments: UrlSegment[]): boolean => { + return inject(GuardService).paymentGuard(route, segments); +}; From 1ed4b8b5fe457a5086e2d7afac25fe0bc7ff0526 Mon Sep 17 00:00:00 2001 From: rodribp Date: Wed, 22 Jul 2026 02:21:52 -0600 Subject: [PATCH 2/3] refactor: Improved UI increased amount --- .../components/payment/payment.component.html | 58 +++++++++------- .../components/payment/payment.component.scss | 67 +++++++++++++------ frontend/src/app/master-page.module.ts | 2 +- 3 files changed, 81 insertions(+), 46 deletions(-) diff --git a/frontend/src/app/components/payment/payment.component.html b/frontend/src/app/components/payment/payment.component.html index bce46564e..bf683adb7 100644 --- a/frontend/src/app/components/payment/payment.component.html +++ b/frontend/src/app/components/payment/payment.component.html @@ -18,8 +18,8 @@
@if (isMobile) {
- - + + @@ -28,15 +28,15 @@ } @else {
- +
-
- +
+
}
@@ -58,9 +58,17 @@
+ +
+ Amount + + +
+
+ - - + @@ -77,6 +85,7 @@ } + @if (tx) { @@ -92,6 +101,7 @@ } + @if (tx && !replaced) { @@ -123,33 +133,31 @@ @if (settled) { Settled } @else if (isValidView && confsRequired > 1) { -
- Confirmed - - @for (conf of confsRequiredArr; track $index) { + - - - + {{ confirmations }} of {{ confsRequired }} Confirmations - } + -
} @else { }
- - @if (isValidView) { -
- -
- } + + + @for (conf of confsRequiredArr; track $index) { + + + + + + } + - + @if (replaced) { -
- Amount - - +
+ @if (viewAmountMode$ | async; as mode) { +
+ BTC + | + Sats +
+
+ Amount + + + + +
+ }
diff --git a/frontend/src/app/components/payment/payment.component.scss b/frontend/src/app/components/payment/payment.component.scss index 9d9723ca7..61289bd1f 100644 --- a/frontend/src/app/components/payment/payment.component.scss +++ b/frontend/src/app/components/payment/payment.component.scss @@ -202,6 +202,33 @@ } } +.toggler { + font-size: 12px; + position: absolute; + top: -20px; + right: 3px; + text-align: right; + + @media (max-width: 850px) { + top: 2px; + } +} + +.toggler-option { + text-decoration: none; +} + +.inactive { + color: var(--transparent-fg); +} + +.amount { + text-decoration: none; + color: white; + width: 100%; + text-align: center; +} + @keyframes pulse { 0% { fill-opacity: 0.30; diff --git a/frontend/src/app/components/payment/payment.component.ts b/frontend/src/app/components/payment/payment.component.ts index f3b02bdbf..d41f9da46 100644 --- a/frontend/src/app/components/payment/payment.component.ts +++ b/frontend/src/app/components/payment/payment.component.ts @@ -19,6 +19,7 @@ import { ETA, EtaService } from '@app/services/eta.service'; import { getRegex } from '@app/shared/regex.utils'; import { TrackerStage } from '@components/tracker/tracker-bar.component'; import { ApiService } from '@app/services/api.service'; +import { StorageService } from '@app/services/storage.service'; @Component({ selector: 'app-payment', @@ -69,6 +70,8 @@ export class PaymentComponent implements OnInit, OnDestroy { fetchCachedTxSubscription: Subscription; txRbfInfoSubscription: Subscription; latestReplacement: string; + amountMode: string; + viewAmountMode$: Observable<'btc' | 'sats' | 'fiat'>; constructor( private route: ActivatedRoute, @@ -83,12 +86,14 @@ export class PaymentComponent implements OnInit, OnDestroy { private seoService: SeoService, private miningService: MiningService, private etaService: EtaService, + private storageService: StorageService, private cd: ChangeDetectorRef, @Inject(ZONE_SERVICE) private zoneService: any, ) {} ngOnInit(): void { this.onResize(); + this.viewAmountMode$ = this.stateService.viewAmountMode$.asObservable(); if (!this.stateService.isLiquid()) { this.miningService.getMiningStats('1m').subscribe(stats => { @@ -467,4 +472,11 @@ export class PaymentComponent implements OnInit, OnDestroy { this.txReplacedSubscription?.unsubscribe(); this.leaveTransaction(); } + + changeMode(mode: 'btc' | 'sats'): boolean { + this.storageService.setValue('view-amount-mode', mode); + this.stateService.viewAmountMode$.next(mode); + this.amountMode = mode; + return false; + } }